From d0b5544b40df462d5dbfcf77f1b39d3c7f04bedb Mon Sep 17 00:00:00 2001 From: David Walker Date: Wed, 17 Sep 2025 01:20:37 -0700 Subject: [PATCH 1/3] REFACTOR: Rewrite infiltration to pull state out of React Upcoming projects (auto-infil, the possibility of making infil a work task, etc.) require infiltration state to transition with predictable timing and be under our control, instead of inside React. This refactor accomplishes this by pulling the state out into accompanying model classes. After this, infiltration can theoretically run headless (without UI), although it doesn't actually, and you would quickly be hospitalized due to failing all the minigames. There should be no user-visible changes, aside from the progress-bars scrolling much more smoothly. --- src/Bladeburner/Bladeburner.ts | 5 +- src/Infiltration/Infiltration.ts | 218 +++++++++++++ src/Infiltration/InfiltrationStage.ts | 16 + src/Infiltration/model/BackwardModel.ts | 297 +++++++++++++++++ src/Infiltration/model/BracketModel.ts | 78 +++++ src/Infiltration/model/BribeModel.ts | 115 +++++++ src/Infiltration/model/CheatCodeModel.ts | 57 ++++ src/Infiltration/model/CountdownModel.ts | 19 ++ src/Infiltration/model/Cyberpunk2077Model.ts | 100 ++++++ src/Infiltration/model/Difficulty.ts | 28 ++ src/Infiltration/model/IntroModel.ts | 5 + src/Infiltration/model/MinesweeperModel.ts | 117 +++++++ src/Infiltration/model/SlashModel.ts | 62 ++++ src/Infiltration/model/VictoryModel.ts | 5 + src/Infiltration/model/WireCuttingModel.ts | 144 +++++++++ src/Infiltration/ui/BackwardGame.tsx | 304 +----------------- src/Infiltration/ui/BracketGame.tsx | 90 +----- src/Infiltration/ui/BribeGame.tsx | 154 +-------- src/Infiltration/ui/CheatCodeGame.tsx | 68 +--- src/Infiltration/ui/Countdown.tsx | 28 +- src/Infiltration/ui/Cyberpunk2077Game.tsx | 113 +------ src/Infiltration/ui/Difficulty.ts | 29 -- src/Infiltration/ui/Game.tsx | 204 ------------ src/Infiltration/ui/GameTimer.tsx | 76 ++--- src/Infiltration/ui/IMinigameProps.tsx | 8 - src/Infiltration/ui/InfiltrationRoot.tsx | 165 +++++++--- src/Infiltration/ui/Intro.tsx | 73 +++-- src/Infiltration/ui/KeyHandler.tsx | 23 -- src/Infiltration/ui/MinesweeperGame.tsx | 135 ++------ src/Infiltration/ui/SlashGame.tsx | 109 +------ src/Infiltration/ui/Victory.tsx | 42 ++- src/Infiltration/ui/WireCuttingGame.tsx | 185 +---------- src/Infiltration/utils.ts | 3 +- src/Locations/ui/CompanyLocation.tsx | 5 +- src/PersonObjects/Player/PlayerObject.ts | 7 +- .../Player/PlayerObjectGeneralMethods.ts | 9 + src/ui/Enums.ts | 2 +- src/ui/GameRoot.tsx | 2 +- src/ui/Router.ts | 3 - 39 files changed, 1607 insertions(+), 1496 deletions(-) create mode 100644 src/Infiltration/Infiltration.ts create mode 100644 src/Infiltration/InfiltrationStage.ts create mode 100644 src/Infiltration/model/BackwardModel.ts create mode 100644 src/Infiltration/model/BracketModel.ts create mode 100644 src/Infiltration/model/BribeModel.ts create mode 100644 src/Infiltration/model/CheatCodeModel.ts create mode 100644 src/Infiltration/model/CountdownModel.ts create mode 100644 src/Infiltration/model/Cyberpunk2077Model.ts create mode 100644 src/Infiltration/model/Difficulty.ts create mode 100644 src/Infiltration/model/IntroModel.ts create mode 100644 src/Infiltration/model/MinesweeperModel.ts create mode 100644 src/Infiltration/model/SlashModel.ts create mode 100644 src/Infiltration/model/VictoryModel.ts create mode 100644 src/Infiltration/model/WireCuttingModel.ts delete mode 100644 src/Infiltration/ui/Difficulty.ts delete mode 100644 src/Infiltration/ui/Game.tsx delete mode 100644 src/Infiltration/ui/IMinigameProps.tsx delete mode 100644 src/Infiltration/ui/KeyHandler.tsx diff --git a/src/Bladeburner/Bladeburner.ts b/src/Bladeburner/Bladeburner.ts index f5455d2812..105f916ca3 100644 --- a/src/Bladeburner/Bladeburner.ts +++ b/src/Bladeburner/Bladeburner.ts @@ -50,7 +50,7 @@ import { PlayerObject } from "../PersonObjects/Player/PlayerObject"; import { Sleeve } from "../PersonObjects/Sleeve/Sleeve"; import { autoCompleteTypeShorthand } from "./utils/terminalShorthands"; import { resolveTeamCasualties, type OperationTeam } from "./Actions/TeamCasualties"; -import { shuffleArray } from "../Infiltration/ui/BribeGame"; +import { shuffle } from "lodash"; import { assertObject } from "../utils/TypeAssertion"; import { throwIfReachable } from "../utils/helpers/throwIfReachable"; import { loadActionIdentifier } from "./utils/loadActionIdentifier"; @@ -749,8 +749,7 @@ export class Bladeburner implements OperationTeam { } killRandomSupportingSleeves(n: number) { - const sup = [...Player.sleevesSupportingBladeburner()]; // Explicit shallow copy - shuffleArray(sup); + const sup = shuffle(Player.sleevesSupportingBladeburner()); // Makes a copy sup.slice(0, Math.min(sup.length, n)).forEach((sleeve) => sleeve.kill()); } diff --git a/src/Infiltration/Infiltration.ts b/src/Infiltration/Infiltration.ts new file mode 100644 index 0000000000..b392a4e15a --- /dev/null +++ b/src/Infiltration/Infiltration.ts @@ -0,0 +1,218 @@ +import type { InfiltrationStage } from "./InfiltrationStage"; +import { AugmentationName, FactionName, ToastVariant } from "@enums"; +import { Player } from "@player"; +import { Page } from "../ui/Router"; +import { Router } from "../ui/GameRoot"; +import { Location } from "../Locations/Location"; +import { EventEmitter } from "../utils/EventEmitter"; +import { PlayerEvents, PlayerEventType } from "../PersonObjects/Player/PlayerEvents"; +import { dialogBoxCreate } from "../ui/React/DialogBox"; +import { SnackbarEvents } from "../ui/React/Snackbar"; +import { CountdownModel } from "./model/CountdownModel"; +import { IntroModel } from "./model/IntroModel"; +import { BackwardModel } from "./model/BackwardModel"; +import { BracketModel } from "./model/BracketModel"; +import { BribeModel } from "./model/BribeModel"; +import { CheatCodeModel } from "./model/CheatCodeModel"; +import { Cyberpunk2077Model } from "./model/Cyberpunk2077Model"; +import { MinesweeperModel } from "./model/MinesweeperModel"; +import { SlashModel } from "./model/SlashModel"; +import { WireCuttingModel } from "./model/WireCuttingModel"; +import { VictoryModel } from "./model/VictoryModel"; +import { calculateDifficulty, MaxDifficultyForInfiltration } from "./formulas/game"; +import { calculateDamageAfterFailingInfiltration } from "./utils"; + +const minigames = [ + BackwardModel, + BracketModel, + BribeModel, + CheatCodeModel, + Cyberpunk2077Model, + MinesweeperModel, + SlashModel, + WireCuttingModel, +] as const; + +export class Infiltration { + location: Location; + startingSecurityLevel: number; + startingDifficulty: number; + /** Note that levels are 1-indexed! maxLevel is inclusive. */ + level = 1; + maxLevel: number; + /** Checkmarks that represent success/failure per-stage. */ + results = ""; + + /** Used to avoid repeating games too quickly. gameIds[0] is the current (or last) game. */ + gameIds = [-1, -1, -1]; + + /** Invalid until infiltration is started, used to calculate rewards */ + gameStartTimestamp = -1; + /** undefined for timeouts that have finished. Typescript isn't happy with null. */ + stageEndTimestamp = -1; + timeoutIds: (ReturnType | undefined)[] = []; + + stage: InfiltrationStage; + + /** Signals when the UI needs to update. */ + updateEvent = new EventEmitter<[]>(); + + /** Cancels our subscription to hospitalization events. */ + clearSubscription: null | (() => void) = null; + + constructor(location: Location) { + if (!location.infiltrationData) { + throw new Error(`You tried to infiltrate an invalid location: ${location.name}`); + } + this.location = location; + this.startingSecurityLevel = location.infiltrationData.startingSecurityLevel; + this.maxLevel = location.infiltrationData.maxClearanceLevel; + this.startingDifficulty = calculateDifficulty(this.startingSecurityLevel); + this.stage = new IntroModel(); + this.clearSubscription = PlayerEvents.subscribe((eventType) => { + if (eventType !== PlayerEventType.Hospitalized) { + return; + } + this.cancel(); + dialogBoxCreate("Infiltration was cancelled because you were hospitalized"); + }); + } + + difficulty(): number { + return this.startingDifficulty + this.level / 50; + } + + startInfiltration() { + this.gameStartTimestamp = Date.now(); + if (this.startingDifficulty >= MaxDifficultyForInfiltration) { + setTimeout(() => { + SnackbarEvents.emit( + "You were discovered immediately. That location is far too secure for your current skill level.", + ToastVariant.ERROR, + 5000, + ); + }, 500); + Player.takeDamage(Player.hp.current); + this.cancel(); + return; + } + this.stage = new CountdownModel(this); + this.updateEvent.emit(); + } + + /** + * Adds a callback to the EventEmitter. This wraps the callback in a check + * that the stage active during registration is currently active, so that + * if the component is switched out, we don't do anything. + */ + addStageCallback(cb: () => void): () => void { + const currentStage = this.stage; + return this.updateEvent.subscribe(() => { + if (currentStage !== this.stage) return; + return cb(); + }); + } + + onSuccess(): void { + this.results += "✓"; + this.clearTimeouts(); + if (this.level >= this.maxLevel) { + this.stage = new VictoryModel(); + this.cleanup(); + } else { + this.stage = new CountdownModel(this); + this.level += 1; + } + this.updateEvent.emit(); + } + + onFailure(options?: { automated?: boolean }): void { + this.results += "✗"; + this.clearTimeouts(); + this.stage = new CountdownModel(this); + Player.receiveRumor(FactionName.ShadowsOfAnarchy); + let damage = calculateDamageAfterFailingInfiltration(this.startingSecurityLevel); + // Kill the player immediately if they use automation, so it's clear they're not meant to + if (options?.automated) { + damage = Player.hp.current; + setTimeout(() => { + SnackbarEvents.emit("You were hospitalized. Do not try to automate infiltration!", ToastVariant.WARNING, 5000); + }, 500); + } + if (Player.takeDamage(damage)) { + this.cancel(); + return; + } + this.updateEvent.emit(); + } + + setStageTime(currentStage: InfiltrationStage, durationMs: number): void { + if (Player.hasAugmentation(AugmentationName.WKSharmonizer, true)) { + durationMs *= 1.3; + } + this.stageEndTimestamp = performance.now() + durationMs; + this.clearTimeouts(); + this.timeoutIds.push( + setTimeout(() => { + this.timeoutIds = []; + if (currentStage !== this.stage) return; + this.onFailure(); + }, durationMs), + ); + } + + setTimeSequence(currentStage: InfiltrationStage, durations: number[], callback: (idx: number) => void): void { + this.clearTimeouts(); + let total = 0; + for (let i = 0; i < durations.length; ++i) { + total += durations[i]; + this.timeoutIds.push( + setTimeout(() => { + this.timeoutIds[i] = undefined; + if (i >= durations.length) { + this.timeoutIds = []; + } + if (currentStage === this.stage) { + callback(i); + } + }, total), + ); + } + this.stageEndTimestamp = performance.now() + total; + } + + newGame(): void { + let id = this.gameIds[0]; + while (this.gameIds.includes(id)) { + id = Math.floor(Math.random() * minigames.length); + } + this.gameIds.unshift(id); + this.gameIds.pop(); + this.stage = new minigames[id](this); + this.updateEvent.emit(); + } + + clearTimeouts(): void { + for (const id of this.timeoutIds) { + clearTimeout(id); + } + this.timeoutIds = []; + } + + cleanup(): void { + this.clearTimeouts(); + this.clearSubscription?.(); + this.clearSubscription = null; + } + + cancel(): void { + this.cleanup(); + if (Player.infiltration !== this) { + return; + } + Player.infiltration = null; + if (Router.page() === Page.Infiltration) { + Router.toPage(Page.City); + } + } +} diff --git a/src/Infiltration/InfiltrationStage.ts b/src/Infiltration/InfiltrationStage.ts new file mode 100644 index 0000000000..317a10afb1 --- /dev/null +++ b/src/Infiltration/InfiltrationStage.ts @@ -0,0 +1,16 @@ +/** + * A subset of the true KeyboardEvent, this contains only the properties we + * actually guarantee to set. + */ +export interface KeyboardLikeEvent { + key: string; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + preventDefault?: () => void; +} + +export interface InfiltrationStage { + onKey: (event: KeyboardLikeEvent) => void; +} diff --git a/src/Infiltration/model/BackwardModel.ts b/src/Infiltration/model/BackwardModel.ts new file mode 100644 index 0000000000..2de5634144 --- /dev/null +++ b/src/Infiltration/model/BackwardModel.ts @@ -0,0 +1,297 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { interpolate } from "./Difficulty"; +import { randomInRange } from "../../utils/helpers/randomInRange"; + +interface Settings { + timer: number; + min: number; + max: number; +} + +const difficultySettings = { + Trivial: { timer: 16000, min: 3, max: 4 }, + Normal: { timer: 12500, min: 2, max: 3 }, + Hard: { timer: 15000, min: 3, max: 4 }, + Brutal: { timer: 8000, min: 4, max: 4 }, +}; + +function ignorableKeyboardEvent(event: KeyboardLikeEvent): boolean { + return event.key === KEY.BACKSPACE || (event.shiftKey && event.key === "Shift") || event.ctrlKey || event.altKey; +} + +function makeAnswer(settings: Settings): string { + const length = randomInRange(settings.min, settings.max); + let answer = ""; + for (let i = 0; i < length; i++) { + if (i > 0) answer += " "; + answer += words[Math.floor(Math.random() * words.length)]; + } + + return answer; +} + +export class BackwardModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + guess = ""; + answer: string; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + if (ignorableKeyboardEvent(event)) return; + this.guess += event.key.toUpperCase(); + if (this.answer === this.guess) { + return this.state.onSuccess(); + } + if (!this.answer.startsWith(this.guess)) { + return this.state.onFailure(); + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + this.answer = makeAnswer(this.settings); + } +} + +const words = [ + "ALGORITHM", + "ANALOG", + "APP", + "APPLICATION", + "ARRAY", + "BACKUP", + "BANDWIDTH", + "BINARY", + "BIT", + "BITE", + "BITMAP", + "BLOG", + "BLOGGER", + "BOOKMARK", + "BOOT", + "BROADBAND", + "BROWSER", + "BUFFER", + "BUG", + "BUS", + "BYTE", + "CACHE", + "CAPS LOCK", + "CAPTCHA", + "CD", + "CD-ROM", + "CLIENT", + "CLIPBOARD", + "CLOUD", + "COMPUTING", + "COMMAND", + "COMPILE", + "COMPRESS", + "COMPUTER", + "CONFIGURE", + "COOKIE", + "COPY", + "CPU", + "CYBERCRIME", + "CYBERSPACE", + "DASHBOARD", + "DATA", + "MINING", + "DATABASE", + "DEBUG", + "DECOMPRESS", + "DELETE", + "DESKTOP", + "DEVELOPMENT", + "DIGITAL", + "DISK", + "DNS", + "DOCUMENT", + "DOMAIN", + "DOMAIN NAME", + "DOT", + "DOT MATRIX", + "DOWNLOAD", + "DRAG", + "DVD", + "DYNAMIC", + "EMAIL", + "EMOTICON", + "ENCRYPT", + "ENCRYPTION", + "ENTER", + "EXABYTE", + "FAQ", + "FILE", + "FINDER", + "FIREWALL", + "FIRMWARE", + "FLAMING", + "FLASH", + "FLASH DRIVE", + "FLOPPY DISK", + "FLOWCHART", + "FOLDER", + "FONT", + "FORMAT", + "FRAME", + "FREEWARE", + "GIGABYTE", + "GRAPHICS", + "HACK", + "HACKER", + "HARDWARE", + "HOME PAGE", + "HOST", + "HTML", + "HYPERLINK", + "HYPERTEXT", + "ICON", + "INBOX", + "INTEGER", + "INTERFACE", + "INTERNET", + "IP ADDRESS", + "ITERATION", + "JAVA", + "JOYSTICK", + "JUNKMAIL", + "KERNEL", + "KEY", + "KEYBOARD", + "KEYWORD", + "LAPTOP", + "LASER PRINTER", + "LINK", + "LINUX", + "LOG OUT", + "LOGIC", + "LOGIN", + "LURKING", + "MACINTOSH", + "MACRO", + "MAINFRAME", + "MALWARE", + "MEDIA", + "MEMORY", + "MIRROR", + "MODEM", + "MONITOR", + "MOTHERBOARD", + "MOUSE", + "MULTIMEDIA", + "NET", + "NETWORK", + "NODE", + "NOTEBOOK", + "COMPUTER", + "OFFLINE", + "ONLINE", + "OPENSOURCE", + "OPERATING", + "SYSTEM", + "OPTION", + "OUTPUT", + "PAGE", + "PASSWORD", + "PASTE", + "PATH", + "PHISHING", + "PIRACY", + "PIRATE", + "PLATFORM", + "PLUGIN", + "PODCAST", + "POPUP", + "PORTAL", + "PRINT", + "PRINTER", + "PRIVACY", + "PROCESS", + "PROGRAM", + "PROGRAMMER", + "PROTOCOL", + "QUEUE", + "QWERTY", + "RAM", + "REALTIME", + "REBOOT", + "RESOLUTION", + "RESTORE", + "ROM", + "ROOT", + "ROUTER", + "RUNTIME", + "SAVE", + "SCAN", + "SCANNER", + "SCREEN", + "SCREENSHOT", + "SCRIPT", + "SCROLL", + "SCROLL", + "SEARCH", + "ENGINE", + "SECURITY", + "SERVER", + "SHAREWARE", + "SHELL", + "SHIFT", + "SHIFT KEY", + "SNAPSHOT", + "SOCIAL NETWORKING", + "SOFTWARE", + "SPAM", + "SPAMMER", + "SPREADSHEET", + "SPYWARE", + "STATUS", + "STORAGE", + "SUPERCOMPUTER", + "SURF", + "SYNTAX", + "TABLE", + "TAG", + "TERMINAL", + "TEMPLATE", + "TERABYTE", + "TEXT EDITOR", + "THREAD", + "TOOLBAR", + "TRASH", + "TROJAN HORSE", + "TYPEFACE", + "UNDO", + "UNIX", + "UPLOAD", + "URL", + "USER", + "USER INTERFACE", + "USERNAME", + "UTILITY", + "VERSION", + "VIRTUAL", + "VIRTUAL MEMORY", + "VIRUS", + "WEB", + "WEBMASTER", + "WEBSITE", + "WIDGET", + "WIKI", + "WINDOW", + "WINDOWS", + "WIRELESS", + "PROCESSOR", + "WORKSTATION", + "WEB", + "WORM", + "WWW", + "XML", + "ZIP", +]; diff --git a/src/Infiltration/model/BracketModel.ts b/src/Infiltration/model/BracketModel.ts new file mode 100644 index 0000000000..c83a48658a --- /dev/null +++ b/src/Infiltration/model/BracketModel.ts @@ -0,0 +1,78 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { AugmentationName } from "@enums"; +import { Player } from "@player"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { interpolate } from "./Difficulty"; +import { randomInRange } from "../../utils/helpers/randomInRange"; + +interface Settings { + timer: number; + min: number; + max: number; +} + +const difficultySettings = { + Trivial: { timer: 8000, min: 2, max: 3 }, + Normal: { timer: 6000, min: 4, max: 5 }, + Hard: { timer: 4000, min: 4, max: 6 }, + Brutal: { timer: 2500, min: 7, max: 7 }, +}; + +function generateLeftSide(settings: Settings): string { + let str = ""; + const options = [KEY.OPEN_BRACKET, KEY.LESS_THAN, KEY.OPEN_PARENTHESIS, KEY.OPEN_BRACE]; + if (Player.hasAugmentation(AugmentationName.WisdomOfAthena, true)) { + options.splice(0, 1); + } + const length = randomInRange(settings.min, settings.max); + for (let i = 0; i < length; i++) { + str += options[Math.floor(Math.random() * options.length)]; + } + + return str; +} + +function getChar(event: KeyboardLikeEvent): string { + if (([KEY.CLOSE_PARENTHESIS, KEY.CLOSE_BRACKET, KEY.CLOSE_BRACE, KEY.GREATER_THAN] as string[]).includes(event.key)) { + return event.key; + } + return ""; +} + +function match(left: string, right: string): boolean { + return ( + (left === KEY.OPEN_BRACKET && right === KEY.CLOSE_BRACKET) || + (left === KEY.LESS_THAN && right === KEY.GREATER_THAN) || + (left === KEY.OPEN_PARENTHESIS && right === KEY.CLOSE_PARENTHESIS) || + (left === KEY.OPEN_BRACE && right === KEY.CLOSE_BRACE) + ); +} + +export class BracketModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + left: string; + right = ""; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + const char = getChar(event); + if (!char) return; + this.right += char; + if (!match(this.left[this.left.length - this.right.length], char)) { + return this.state.onFailure(); + } + if (this.left.length === this.right.length) { + return this.state.onSuccess(); + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + this.left = generateLeftSide(this.settings); + } +} diff --git a/src/Infiltration/model/BribeModel.ts b/src/Infiltration/model/BribeModel.ts new file mode 100644 index 0000000000..bfe51eab24 --- /dev/null +++ b/src/Infiltration/model/BribeModel.ts @@ -0,0 +1,115 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { shuffle } from "lodash"; +import { interpolate } from "./Difficulty"; + +interface Settings { + timer: number; + size: number; +} + +const difficultySettings = { + Trivial: { timer: 12000, size: 6 }, + Normal: { timer: 9000, size: 8 }, + Hard: { timer: 5000, size: 9 }, + Brutal: { timer: 2500, size: 12 }, +}; + +function makeChoices(settings: Settings): string[] { + const choices = []; + choices.push(positive[Math.floor(Math.random() * positive.length)]); + for (let i = 0; i < settings.size; i++) { + const option = negative[Math.floor(Math.random() * negative.length)]; + if (choices.includes(option)) { + i--; + continue; + } + choices.push(option); + } + return shuffle(choices); +} + +export class BribeModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + choices: string[]; + correctIndex = 0; + index = 0; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + + const k = event.key; + if (k === KEY.SPACE) { + if (positive.includes(this.choices[this.index])) { + this.state.onSuccess(); + } else { + this.state.onFailure(); + } + return; + } + + if (([KEY.UP_ARROW, KEY.W, KEY.RIGHT_ARROW, KEY.D] as string[]).includes(k)) this.index++; + if (([KEY.DOWN_ARROW, KEY.S, KEY.LEFT_ARROW, KEY.A] as string[]).includes(k)) this.index--; + while (this.index < 0) this.index += this.choices.length; + while (this.index >= this.choices.length) this.index -= this.choices.length; + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + this.choices = makeChoices(this.settings); + this.correctIndex = this.choices.findIndex((choice) => positive.includes(choice)); + } +} + +const positive = [ + "affectionate", + "agreeable", + "bright", + "charming", + "creative", + "determined", + "energetic", + "friendly", + "funny", + "generous", + "polite", + "likable", + "diplomatic", + "helpful", + "giving", + "kind", + "hardworking", + "patient", + "dynamic", + "loyal", + "straightforward", +]; + +const negative = [ + "aggressive", + "aloof", + "arrogant", + "big-headed", + "boastful", + "boring", + "bossy", + "careless", + "clingy", + "couch potato", + "cruel", + "cynical", + "grumpy", + "hot air", + "know it all", + "obnoxious", + "pain in the neck", + "picky", + "tactless", + "thoughtless", + "cringe", +]; diff --git a/src/Infiltration/model/CheatCodeModel.ts b/src/Infiltration/model/CheatCodeModel.ts new file mode 100644 index 0000000000..fd41cae17a --- /dev/null +++ b/src/Infiltration/model/CheatCodeModel.ts @@ -0,0 +1,57 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { interpolate } from "./Difficulty"; +import { type Arrow, downArrowSymbol, getArrow, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; +import { randomInRange } from "../../utils/helpers/randomInRange"; + +interface Settings { + timer: number; + min: number; + max: number; +} + +const difficultySettings = { + Trivial: { timer: 13000, min: 6, max: 8 }, + Normal: { timer: 7000, min: 7, max: 8 }, + Hard: { timer: 5000, min: 8, max: 9 }, + Brutal: { timer: 3000, min: 9, max: 10 }, +}; + +function generateCode(settings: Settings): Arrow[] { + const arrows: Arrow[] = [leftArrowSymbol, rightArrowSymbol, upArrowSymbol, downArrowSymbol]; + const code: Arrow[] = []; + for (let i = 0; i < randomInRange(settings.min, settings.max); i++) { + let arrow = arrows[Math.floor(4 * Math.random())]; + while (arrow === code[code.length - 1]) { + arrow = arrows[Math.floor(4 * Math.random())]; + } + code.push(arrow); + } + return code; +} + +export class CheatCodeModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + index = 0; + code: Arrow[]; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + if (this.code[this.index] !== getArrow(event)) { + return this.state.onFailure(); + } + this.index += 1; + if (this.index >= this.code.length) { + return this.state.onSuccess(); + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + this.code = generateCode(this.settings); + } +} diff --git a/src/Infiltration/model/CountdownModel.ts b/src/Infiltration/model/CountdownModel.ts new file mode 100644 index 0000000000..e91bc8fcee --- /dev/null +++ b/src/Infiltration/model/CountdownModel.ts @@ -0,0 +1,19 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; + +export class CountdownModel implements InfiltrationStage { + count = 3; + + onKey(__: KeyboardLikeEvent) {} + + constructor(state: Infiltration) { + state.setTimeSequence(this, [300, 300, 300], (i) => { + this.count = 2 - i; + if (this.count) { + state.updateEvent.emit(); + } else { + state.newGame(); + } + }); + } +} diff --git a/src/Infiltration/model/Cyberpunk2077Model.ts b/src/Infiltration/model/Cyberpunk2077Model.ts new file mode 100644 index 0000000000..fc1c276293 --- /dev/null +++ b/src/Infiltration/model/Cyberpunk2077Model.ts @@ -0,0 +1,100 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { interpolate } from "./Difficulty"; +import { getArrow, downArrowSymbol, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; + +interface Settings { + timer: number; + width: number; + height: number; + symbols: number; +} + +const difficultySettings = { + Trivial: { timer: 12500, width: 3, height: 3, symbols: 6 }, + Normal: { timer: 15000, width: 4, height: 4, symbols: 7 }, + Hard: { timer: 12500, width: 5, height: 5, symbols: 8 }, + Brutal: { timer: 10000, width: 6, height: 6, symbols: 9 }, +}; + +function generateAnswers(grid: string[][], settings: Settings): string[] { + const answers = []; + for (let i = 0; i < settings.symbols; i++) { + answers.push(grid[Math.floor(Math.random() * grid.length)][Math.floor(Math.random() * grid[0].length)]); + } + return answers; +} + +function randChar(): string { + return "ABCDEF0123456789"[Math.floor(Math.random() * 16)]; +} + +function generatePuzzle(settings: Settings): string[][] { + const puzzle = []; + for (let i = 0; i < settings.height; i++) { + const line = []; + for (let j = 0; j < settings.width; j++) { + line.push(randChar() + randChar()); + } + puzzle.push(line); + } + return puzzle; +} + +export class Cyberpunk2077Model implements InfiltrationStage { + state: Infiltration; + settings: Settings; + grid: string[][]; + answers: string[]; + currentAnswerIndex = 0; + x = 0; + y = 0; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + + const move = [0, 0]; + const arrow = getArrow(event); + switch (arrow) { + case upArrowSymbol: + move[1]--; + break; + case leftArrowSymbol: + move[0]--; + break; + case downArrowSymbol: + move[1]++; + break; + case rightArrowSymbol: + move[0]++; + break; + } + this.x = (this.x + move[0] + this.grid[0].length) % this.grid[0].length; + this.y = (this.y + move[1] + this.grid.length) % this.grid.length; + + if (event.key === KEY.SPACE) { + const selected = this.grid[this.y][this.x]; + const expected = this.answers[this.currentAnswerIndex]; + if (selected !== expected) { + return this.state.onFailure(); + } + this.currentAnswerIndex += 1; + if (this.currentAnswerIndex >= this.answers.length) { + return this.state.onSuccess(); + } + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + this.settings.width = Math.round(this.settings.width); + this.settings.height = Math.round(this.settings.height); + this.settings.symbols = Math.round(this.settings.symbols); + this.grid = generatePuzzle(this.settings); + this.answers = generateAnswers(this.grid, this.settings); + } +} diff --git a/src/Infiltration/model/Difficulty.ts b/src/Infiltration/model/Difficulty.ts new file mode 100644 index 0000000000..789811f04e --- /dev/null +++ b/src/Infiltration/model/Difficulty.ts @@ -0,0 +1,28 @@ +interface DifficultySettings { + Trivial: T; + Normal: T; + Hard: T; + Brutal: T; +} + +// interpolates between 2 numbers. +function lerp(x: number, y: number, t: number): number { + return (1 - t) * x + t * y; +} + +// interpolates between 2 difficulties. +function lerpD>(a: T, b: T, t: number): T { + const out: Record = {}; + for (const key of Object.keys(a)) { + out[key] = lerp(a[key], b[key], t); + } + return out as T; +} + +export function interpolate>(settings: DifficultySettings, n: number): T { + if (n < 0) return lerpD(settings.Trivial, settings.Trivial, 0); + if (n >= 0 && n < 1) return lerpD(settings.Trivial, settings.Normal, n); + if (n >= 1 && n < 2) return lerpD(settings.Normal, settings.Hard, n - 1); + if (n >= 2 && n < 3) return lerpD(settings.Hard, settings.Brutal, n - 2); + return lerpD(settings.Brutal, settings.Brutal, 0); +} diff --git a/src/Infiltration/model/IntroModel.ts b/src/Infiltration/model/IntroModel.ts new file mode 100644 index 0000000000..03f3a36ed7 --- /dev/null +++ b/src/Infiltration/model/IntroModel.ts @@ -0,0 +1,5 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; + +export class IntroModel implements InfiltrationStage { + onKey(__: KeyboardLikeEvent) {} +} diff --git a/src/Infiltration/model/MinesweeperModel.ts b/src/Infiltration/model/MinesweeperModel.ts new file mode 100644 index 0000000000..1f0fd23512 --- /dev/null +++ b/src/Infiltration/model/MinesweeperModel.ts @@ -0,0 +1,117 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { interpolate } from "./Difficulty"; +import { downArrowSymbol, getArrow, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; +import { Player } from "@player"; +import { AugmentationName } from "@enums"; + +interface Settings { + timer: number; + width: number; + height: number; + mines: number; +} + +const difficultySettings = { + Trivial: { timer: 15000, width: 3, height: 3, mines: 4 }, + Normal: { timer: 15000, width: 4, height: 4, mines: 7 }, + Hard: { timer: 15000, width: 5, height: 5, mines: 11 }, + Brutal: { timer: 15000, width: 6, height: 6, mines: 15 }, +}; + +function fieldEquals(a: boolean[][], b: boolean[][]): boolean { + function count(field: boolean[][]): number { + return field.flat().reduce((a, b) => a + (b ? 1 : 0), 0); + } + return count(a) === count(b); +} + +function generateEmptyField(settings: Settings): boolean[][] { + const field: boolean[][] = []; + for (let i = 0; i < settings.height; i++) { + field.push(new Array(settings.width).fill(false)); + } + return field; +} + +function generateMinefield(settings: Settings): boolean[][] { + const field = generateEmptyField(settings); + for (let i = 0; i < settings.mines; i++) { + const x = Math.floor(Math.random() * field.length); + const y = Math.floor(Math.random() * field[0].length); + if (field[x][y]) { + i--; + continue; + } + field[x][y] = true; + } + return field; +} + +export class MinesweeperModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + x = 0; + y = 0; + minefield: boolean[][]; + answer: boolean[][]; + memoryPhase = true; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + + if (this.memoryPhase) return; + let m_x = 0; + let m_y = 0; + const arrow = getArrow(event); + switch (arrow) { + case upArrowSymbol: + m_y = -1; + break; + case leftArrowSymbol: + m_x = -1; + break; + case downArrowSymbol: + m_y = 1; + break; + case rightArrowSymbol: + m_x = 1; + break; + } + this.x = (this.x + m_x + this.minefield[0].length) % this.minefield[0].length; + this.y = (this.y + m_y + this.minefield.length) % this.minefield.length; + + if (event.key == KEY.SPACE) { + if (!this.minefield[this.y][this.x]) { + return this.state.onFailure(); + } + this.answer[this.y][this.x] = true; + if (fieldEquals(this.minefield, this.answer)) { + return this.state.onSuccess(); + } + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + this.settings = interpolate(difficultySettings, state.difficulty()); + + const hasWKSharmonizer = Player.hasAugmentation(AugmentationName.WKSharmonizer, true); + state.setTimeSequence(this, [2000, this.settings.timer * (hasWKSharmonizer ? 1.3 : 1) - 2000], (i) => { + this.memoryPhase = false; + if (i < 1) { + state.updateEvent.emit(); + } else { + state.onFailure(); + } + }); + + this.settings.width = Math.round(this.settings.width); + this.settings.height = Math.round(this.settings.height); + this.settings.mines = Math.round(this.settings.mines); + this.minefield = generateMinefield(this.settings); + this.answer = generateEmptyField(this.settings); + } +} diff --git a/src/Infiltration/model/SlashModel.ts b/src/Infiltration/model/SlashModel.ts new file mode 100644 index 0000000000..daa628a6e4 --- /dev/null +++ b/src/Infiltration/model/SlashModel.ts @@ -0,0 +1,62 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { Player } from "@player"; +import { AugmentationName } from "@enums"; +import { KEY } from "../../utils/KeyboardEventKey"; +import { interpolate } from "./Difficulty"; + +interface Settings { + window: number; +} + +const difficultySettings = { + Trivial: { window: 800 }, + Normal: { window: 500 }, + Hard: { window: 350 }, + Brutal: { window: 250 }, +}; + +export class SlashModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + phase = 0; + hasMightOfAres = false; + + distractedTime: number; + guardingTime: number; + alertedTime: number; + guardingEndTime: number; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + if (event.key !== KEY.SPACE) return; + if (this.phase !== 1) { + this.state.onFailure(); + } else { + this.state.onSuccess(); + } + } + + constructor(state: Infiltration) { + this.state = state; + + const hasWKSharmonizer = Player.hasAugmentation(AugmentationName.WKSharmonizer, true); + this.hasMightOfAres = Player.hasAugmentation(AugmentationName.MightOfAres, true); + + // Determine time window of phases + this.settings = interpolate(difficultySettings, state.difficulty()); + this.distractedTime = this.settings.window * (hasWKSharmonizer ? 1.3 : 1); + this.alertedTime = 250; + this.guardingTime = Math.random() * 3250 + 1500 - (this.distractedTime + this.alertedTime); + + state.setTimeSequence(this, [this.guardingTime, this.distractedTime, this.alertedTime], (i) => { + this.phase = i + 1; + if (i < 2) { + state.updateEvent.emit(); + } else { + state.onFailure(); + } + }); + this.guardingEndTime = performance.now() + this.guardingTime; + } +} diff --git a/src/Infiltration/model/VictoryModel.ts b/src/Infiltration/model/VictoryModel.ts new file mode 100644 index 0000000000..b884325851 --- /dev/null +++ b/src/Infiltration/model/VictoryModel.ts @@ -0,0 +1,5 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; + +export class VictoryModel implements InfiltrationStage { + onKey(__: KeyboardLikeEvent) {} +} diff --git a/src/Infiltration/model/WireCuttingModel.ts b/src/Infiltration/model/WireCuttingModel.ts new file mode 100644 index 0000000000..d2b762b3d6 --- /dev/null +++ b/src/Infiltration/model/WireCuttingModel.ts @@ -0,0 +1,144 @@ +import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { interpolate } from "./Difficulty"; +import { isPositiveInteger } from "../../types"; +import { randomInRange } from "../../utils/helpers/randomInRange"; + +interface Settings { + timer: number; + wiresmin: number; + wiresmax: number; + rules: number; +} + +const difficultySettings = { + Trivial: { timer: 9000, wiresmin: 4, wiresmax: 4, rules: 2 }, + Normal: { timer: 7000, wiresmin: 6, wiresmax: 6, rules: 2 }, + Hard: { timer: 5000, wiresmin: 8, wiresmax: 8, rules: 3 }, + Brutal: { timer: 4000, wiresmin: 9, wiresmax: 9, rules: 4 }, +}; + +const colors = ["red", "#FFC107", "blue", "white"] as const; + +const colorNames = { + red: "RED", + "#FFC107": "YELLOW", + blue: "BLUE", + white: "WHITE", +} as const; + +interface Wire { + wireType: string; + colors: (keyof typeof colorNames)[]; +} + +interface Question { + toString: () => string; + shouldCut: (wire: Wire, index: number) => boolean; +} + +function randomPositionQuestion(wires: Wire[]): Question { + const index = Math.floor(Math.random() * wires.length); + return { + toString: (): string => { + return `Cut wire number ${index + 1}.`; + }, + shouldCut: (_wire: Wire, i: number): boolean => { + return index === i; + }, + }; +} + +function randomColorQuestion(wires: Wire[]): Question { + const index = Math.floor(Math.random() * wires.length); + const cutColor = wires[index].colors[0]; + return { + toString: (): string => { + return `Cut all wires colored ${colorNames[cutColor]}.`; + }, + shouldCut: (wire: Wire): boolean => { + return wire.colors.includes(cutColor); + }, + }; +} + +function generateQuestion(wires: Wire[], settings: Settings): Question[] { + const numQuestions = settings.rules; + const questionGenerators = [randomPositionQuestion, randomColorQuestion]; + const questions = []; + for (let i = 0; i < numQuestions; i++) { + questions.push(questionGenerators[i % 2](wires)); + } + return questions; +} + +function generateWires(settings: Settings): Wire[] { + const wires = []; + const numWires = randomInRange(settings.wiresmin, settings.wiresmax); + for (let i = 0; i < numWires; i++) { + const wireColors = [colors[Math.floor(Math.random() * colors.length)]]; + if (Math.random() < 0.15) { + wireColors.push(colors[Math.floor(Math.random() * colors.length)]); + } + const wireType = wireColors.map((color) => colorNames[color]).join(""); + wires.push({ + wireType, + colors: wireColors, + }); + } + return wires; +} + +export class WireCuttingModel implements InfiltrationStage { + state: Infiltration; + settings: Settings; + wires: Wire[]; + questions: Question[]; + wiresToCut = new Set(); + cutWires: boolean[]; + + onKey(event: KeyboardLikeEvent): void { + event.preventDefault?.(); + const wireNum = parseInt(event.key); + const wireIndex = wireNum - 1; + if (!isPositiveInteger(wireNum) || wireNum > this.wires.length || this.cutWires[wireIndex]) { + return; + } + this.cutWires[wireIndex] = true; + + // Check if game has been lost + if (!this.wiresToCut.has(wireIndex)) { + return this.state.onFailure(); + } + + // Check if game has been won + this.wiresToCut.delete(wireIndex); + if (this.wiresToCut.size === 0) { + return this.state.onSuccess(); + } + this.state.updateEvent.emit(); + } + + constructor(state: Infiltration) { + this.state = state; + + // Determine game difficulty + this.settings = interpolate(difficultySettings, state.difficulty()); + state.setStageTime(this, this.settings.timer); + + // Calculate initial game data + this.wires = generateWires(this.settings); + this.questions = generateQuestion(this.wires, this.settings); + this.wires.forEach((wire, index) => { + for (const question of this.questions) { + if (question.shouldCut(wire, index)) { + this.wiresToCut.add(index); + return; // go to next wire + } + } + }); + + // Initialize the game state + this.cutWires = this.wires.map((__) => false); + } +} diff --git a/src/Infiltration/ui/BackwardGame.tsx b/src/Infiltration/ui/BackwardGame.tsx index c82cbefba5..222ab4962d 100644 --- a/src/Infiltration/ui/BackwardGame.tsx +++ b/src/Infiltration/ui/BackwardGame.tsx @@ -1,315 +1,29 @@ import { Paper, Typography } from "@mui/material"; -import React, { useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { BackwardModel } from "../model/BackwardModel"; import { AugmentationName } from "@enums"; import { Player } from "@player"; -import { KEY } from "../../utils/KeyboardEventKey"; import { BlinkingCursor } from "./BlinkingCursor"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -import { randomInRange } from "../../utils/helpers/randomInRange"; -interface Difficulty { - [key: string]: number; - timer: number; - min: number; - max: number; +interface IProps { + state: Infiltration; + stage: BackwardModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 16000, min: 3, max: 4 }, - Normal: { timer: 12500, min: 2, max: 3 }, - Hard: { timer: 15000, min: 3, max: 4 }, - Brutal: { timer: 8000, min: 4, max: 4 }, -}; - -export function BackwardGame(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, min: 0, max: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [answer] = useState(makeAnswer(difficulty)); - const [guess, setGuess] = useState(""); +export function BackwardGame({ stage }: IProps): React.ReactElement { const hasAugment = Player.hasAugmentation(AugmentationName.ChaosOfDionysus, true); - function ignorableKeyboardEvent(event: KeyboardEvent): boolean { - return event.key === KEY.BACKSPACE || (event.shiftKey && event.key === "Shift") || event.ctrlKey || event.altKey; - } - - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - if (ignorableKeyboardEvent(event)) return; - const nextGuess = guess + event.key.toUpperCase(); - if (!answer.startsWith(nextGuess)) props.onFailure(); - else if (answer === nextGuess) props.onSuccess(); - else setGuess(nextGuess); - } - return ( <> - Type it{!hasAugment ? " backward" : ""} - - {answer} + {stage.answer} - {guess} + {stage.guess} ); } - -function makeAnswer(difficulty: Difficulty): string { - const length = randomInRange(difficulty.min, difficulty.max); - let answer = ""; - for (let i = 0; i < length; i++) { - if (i > 0) answer += " "; - answer += words[Math.floor(Math.random() * words.length)]; - } - - return answer; -} - -const words = [ - "ALGORITHM", - "ANALOG", - "APP", - "APPLICATION", - "ARRAY", - "BACKUP", - "BANDWIDTH", - "BINARY", - "BIT", - "BITE", - "BITMAP", - "BLOG", - "BLOGGER", - "BOOKMARK", - "BOOT", - "BROADBAND", - "BROWSER", - "BUFFER", - "BUG", - "BUS", - "BYTE", - "CACHE", - "CAPS LOCK", - "CAPTCHA", - "CD", - "CD-ROM", - "CLIENT", - "CLIPBOARD", - "CLOUD", - "COMPUTING", - "COMMAND", - "COMPILE", - "COMPRESS", - "COMPUTER", - "CONFIGURE", - "COOKIE", - "COPY", - "CPU", - "CYBERCRIME", - "CYBERSPACE", - "DASHBOARD", - "DATA", - "MINING", - "DATABASE", - "DEBUG", - "DECOMPRESS", - "DELETE", - "DESKTOP", - "DEVELOPMENT", - "DIGITAL", - "DISK", - "DNS", - "DOCUMENT", - "DOMAIN", - "DOMAIN NAME", - "DOT", - "DOT MATRIX", - "DOWNLOAD", - "DRAG", - "DVD", - "DYNAMIC", - "EMAIL", - "EMOTICON", - "ENCRYPT", - "ENCRYPTION", - "ENTER", - "EXABYTE", - "FAQ", - "FILE", - "FINDER", - "FIREWALL", - "FIRMWARE", - "FLAMING", - "FLASH", - "FLASH DRIVE", - "FLOPPY DISK", - "FLOWCHART", - "FOLDER", - "FONT", - "FORMAT", - "FRAME", - "FREEWARE", - "GIGABYTE", - "GRAPHICS", - "HACK", - "HACKER", - "HARDWARE", - "HOME PAGE", - "HOST", - "HTML", - "HYPERLINK", - "HYPERTEXT", - "ICON", - "INBOX", - "INTEGER", - "INTERFACE", - "INTERNET", - "IP ADDRESS", - "ITERATION", - "JAVA", - "JOYSTICK", - "JUNKMAIL", - "KERNEL", - "KEY", - "KEYBOARD", - "KEYWORD", - "LAPTOP", - "LASER PRINTER", - "LINK", - "LINUX", - "LOG OUT", - "LOGIC", - "LOGIN", - "LURKING", - "MACINTOSH", - "MACRO", - "MAINFRAME", - "MALWARE", - "MEDIA", - "MEMORY", - "MIRROR", - "MODEM", - "MONITOR", - "MOTHERBOARD", - "MOUSE", - "MULTIMEDIA", - "NET", - "NETWORK", - "NODE", - "NOTEBOOK", - "COMPUTER", - "OFFLINE", - "ONLINE", - "OPENSOURCE", - "OPERATING", - "SYSTEM", - "OPTION", - "OUTPUT", - "PAGE", - "PASSWORD", - "PASTE", - "PATH", - "PHISHING", - "PIRACY", - "PIRATE", - "PLATFORM", - "PLUGIN", - "PODCAST", - "POPUP", - "PORTAL", - "PRINT", - "PRINTER", - "PRIVACY", - "PROCESS", - "PROGRAM", - "PROGRAMMER", - "PROTOCOL", - "QUEUE", - "QWERTY", - "RAM", - "REALTIME", - "REBOOT", - "RESOLUTION", - "RESTORE", - "ROM", - "ROOT", - "ROUTER", - "RUNTIME", - "SAVE", - "SCAN", - "SCANNER", - "SCREEN", - "SCREENSHOT", - "SCRIPT", - "SCROLL", - "SCROLL", - "SEARCH", - "ENGINE", - "SECURITY", - "SERVER", - "SHAREWARE", - "SHELL", - "SHIFT", - "SHIFT KEY", - "SNAPSHOT", - "SOCIAL NETWORKING", - "SOFTWARE", - "SPAM", - "SPAMMER", - "SPREADSHEET", - "SPYWARE", - "STATUS", - "STORAGE", - "SUPERCOMPUTER", - "SURF", - "SYNTAX", - "TABLE", - "TAG", - "TERMINAL", - "TEMPLATE", - "TERABYTE", - "TEXT EDITOR", - "THREAD", - "TOOLBAR", - "TRASH", - "TROJAN HORSE", - "TYPEFACE", - "UNDO", - "UNIX", - "UPLOAD", - "URL", - "USER", - "USER INTERFACE", - "USERNAME", - "UTILITY", - "VERSION", - "VIRTUAL", - "VIRTUAL MEMORY", - "VIRUS", - "WEB", - "WEBMASTER", - "WEBSITE", - "WIDGET", - "WIKI", - "WINDOW", - "WINDOWS", - "WIRELESS", - "PROCESSOR", - "WORKSTATION", - "WEB", - "WORM", - "WWW", - "XML", - "ZIP", -]; diff --git a/src/Infiltration/ui/BracketGame.tsx b/src/Infiltration/ui/BracketGame.tsx index a42cb79a38..4ba703f415 100644 --- a/src/Infiltration/ui/BracketGame.tsx +++ b/src/Infiltration/ui/BracketGame.tsx @@ -1,97 +1,23 @@ import { Paper, Typography } from "@mui/material"; -import React, { useState } from "react"; -import { AugmentationName } from "@enums"; -import { Player } from "@player"; -import { KEY } from "../../utils/KeyboardEventKey"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { BracketModel } from "../model/BracketModel"; import { BlinkingCursor } from "./BlinkingCursor"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -import { randomInRange } from "../../utils/helpers/randomInRange"; -interface Difficulty { - [key: string]: number; - timer: number; - min: number; - max: number; +interface IProps { + state: Infiltration; + stage: BracketModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 8000, min: 2, max: 3 }, - Normal: { timer: 6000, min: 4, max: 5 }, - Hard: { timer: 4000, min: 4, max: 6 }, - Brutal: { timer: 2500, min: 7, max: 7 }, -}; - -function generateLeftSide(difficulty: Difficulty): string { - let str = ""; - const options = [KEY.OPEN_BRACKET, KEY.LESS_THAN, KEY.OPEN_PARENTHESIS, KEY.OPEN_BRACE]; - if (Player.hasAugmentation(AugmentationName.WisdomOfAthena, true)) { - options.splice(0, 1); - } - const length = randomInRange(difficulty.min, difficulty.max); - for (let i = 0; i < length; i++) { - str += options[Math.floor(Math.random() * options.length)]; - } - - return str; -} - -function getChar(event: KeyboardEvent): string { - if (event.key === KEY.CLOSE_PARENTHESIS) return KEY.CLOSE_PARENTHESIS; - if (event.key === KEY.CLOSE_BRACKET) return KEY.CLOSE_BRACKET; - if (event.key === KEY.CLOSE_BRACE) return KEY.CLOSE_BRACE; - if (event.key === KEY.GREATER_THAN) return KEY.GREATER_THAN; - return ""; -} - -function match(left: string, right: string): boolean { - return ( - (left === KEY.OPEN_BRACKET && right === KEY.CLOSE_BRACKET) || - (left === KEY.LESS_THAN && right === KEY.GREATER_THAN) || - (left === KEY.OPEN_PARENTHESIS && right === KEY.CLOSE_PARENTHESIS) || - (left === KEY.OPEN_BRACE && right === KEY.CLOSE_BRACE) - ); -} - -export function BracketGame(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, min: 0, max: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [right, setRight] = useState(""); - const [left] = useState(generateLeftSide(difficulty)); - - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - const char = getChar(event); - if (!char) return; - if (!match(left[left.length - right.length - 1], char)) { - props.onFailure(); - return; - } - if (left.length === right.length + 1) { - props.onSuccess(); - return; - } - setRight(right + char); - } - +export function BracketGame({ stage }: IProps): React.ReactElement { return ( <> - Close the brackets - {`${left}${right}`} + {`${stage.left}${stage.right}`} - ); diff --git a/src/Infiltration/ui/BribeGame.tsx b/src/Infiltration/ui/BribeGame.tsx index 3c85dd0343..e2ccee6ab7 100644 --- a/src/Infiltration/ui/BribeGame.tsx +++ b/src/Infiltration/ui/BribeGame.tsx @@ -1,47 +1,19 @@ import { Paper, Typography } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { BribeModel } from "../model/BribeModel"; import { AugmentationName } from "@enums"; import { Player } from "@player"; import { Settings } from "../../Settings/Settings"; -import { KEY } from "../../utils/KeyboardEventKey"; import { downArrowSymbol, upArrowSymbol } from "../utils"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -interface Difficulty { - [key: string]: number; - - timer: number; - size: number; +interface IProps { + state: Infiltration; + stage: BribeModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 12000, size: 6 }, - Normal: { timer: 9000, size: 8 }, - Hard: { timer: 5000, size: 9 }, - Brutal: { timer: 2500, size: 12 }, -}; - -export function BribeGame(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, size: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [choices] = useState(makeChoices(difficulty)); - const [correctIndex, setCorrectIndex] = useState(0); - const [index, setIndex] = useState(0); - const currentChoice = choices[index]; - - useEffect(() => { - setCorrectIndex(choices.findIndex((choice) => positive.includes(choice))); - }, [choices]); - +export function BribeGame({ stage }: IProps): React.ReactElement { + const currentChoice = stage.choices[stage.index]; const defaultColor = Settings.theme.primary; const disabledColor = Settings.theme.disabled; let upColor = defaultColor; @@ -50,49 +22,29 @@ export function BribeGame(props: IMinigameProps): React.ReactElement { const hasAugment = Player.hasAugmentation(AugmentationName.BeautyOfAphrodite, true); if (hasAugment) { - const upIndex = index + 1 >= choices.length ? 0 : index + 1; - let upDistance = correctIndex - upIndex; - if (upIndex > correctIndex) { - upDistance = choices.length - 1 - upIndex + correctIndex; + const upIndex = stage.index + 1 >= stage.choices.length ? 0 : stage.index + 1; + let upDistance = stage.correctIndex - upIndex; + if (upIndex > stage.correctIndex) { + upDistance = stage.choices.length - 1 - upIndex + stage.correctIndex; } - const downIndex = index - 1 < 0 ? choices.length - 1 : index - 1; - let downDistance = downIndex - correctIndex; - if (downIndex < correctIndex) { - downDistance = downIndex + choices.length - 1 - correctIndex; + const downIndex = stage.index - 1 < 0 ? stage.choices.length - 1 : stage.index - 1; + let downDistance = downIndex - stage.correctIndex; + if (downIndex < stage.correctIndex) { + downDistance = downIndex + stage.choices.length - 1 - stage.correctIndex; } - const onCorrectIndex = correctIndex == index; + const onCorrectIndex = stage.correctIndex === stage.index; upColor = upDistance <= downDistance && !onCorrectIndex ? upColor : disabledColor; downColor = upDistance >= downDistance && !onCorrectIndex ? downColor : disabledColor; choiceColor = onCorrectIndex ? defaultColor : disabledColor; } - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - - const k = event.key; - if (k === KEY.SPACE) { - if (positive.includes(currentChoice)) props.onSuccess(); - else props.onFailure(); - return; - } - - let newIndex = index; - if ([KEY.UP_ARROW, KEY.W, KEY.RIGHT_ARROW, KEY.D].map((k) => k as string).includes(k)) newIndex++; - if ([KEY.DOWN_ARROW, KEY.S, KEY.LEFT_ARROW, KEY.A].map((k) => k as string).includes(k)) newIndex--; - while (newIndex < 0) newIndex += choices.length; - while (newIndex > choices.length - 1) newIndex -= choices.length; - setIndex(newIndex); - } - return ( <> - Say something nice about the guard - {upArrowSymbol} @@ -106,75 +58,3 @@ export function BribeGame(props: IMinigameProps): React.ReactElement { ); } - -export function shuffleArray(array: unknown[]): void { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const temp = array[i]; - array[i] = array[j]; - array[j] = temp; - } -} - -function makeChoices(difficulty: Difficulty): string[] { - const choices = []; - choices.push(positive[Math.floor(Math.random() * positive.length)]); - for (let i = 0; i < difficulty.size; i++) { - const option = negative[Math.floor(Math.random() * negative.length)]; - if (choices.includes(option)) { - i--; - continue; - } - choices.push(option); - } - shuffleArray(choices); - return choices; -} - -const positive = [ - "affectionate", - "agreeable", - "bright", - "charming", - "creative", - "determined", - "energetic", - "friendly", - "funny", - "generous", - "polite", - "likable", - "diplomatic", - "helpful", - "giving", - "kind", - "hardworking", - "patient", - "dynamic", - "loyal", - "straightforward", -]; - -const negative = [ - "aggressive", - "aloof", - "arrogant", - "big-headed", - "boastful", - "boring", - "bossy", - "careless", - "clingy", - "couch potato", - "cruel", - "cynical", - "grumpy", - "hot air", - "know it all", - "obnoxious", - "pain in the neck", - "picky", - "tactless", - "thoughtless", - "cringe", -]; diff --git a/src/Infiltration/ui/CheatCodeGame.tsx b/src/Infiltration/ui/CheatCodeGame.tsx index 15a71fb1ac..a3d791bbad 100644 --- a/src/Infiltration/ui/CheatCodeGame.tsx +++ b/src/Infiltration/ui/CheatCodeGame.tsx @@ -1,54 +1,20 @@ import { Paper, Typography } from "@mui/material"; -import React, { useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { CheatCodeModel } from "../model/CheatCodeModel"; import { AugmentationName } from "@enums"; import { Player } from "@player"; -import { Arrow, downArrowSymbol, getArrow, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -import { randomInRange } from "../../utils/helpers/randomInRange"; -interface Difficulty { - [key: string]: number; - timer: number; - min: number; - max: number; +interface IProps { + state: Infiltration; + stage: CheatCodeModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 13000, min: 6, max: 8 }, - Normal: { timer: 7000, min: 7, max: 8 }, - Hard: { timer: 5000, min: 8, max: 9 }, - Brutal: { timer: 3000, min: 9, max: 10 }, -}; - -export function CheatCodeGame(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, min: 0, max: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [code] = useState(generateCode(difficulty)); - const [index, setIndex] = useState(0); +export function CheatCodeGame({ stage }: IProps): React.ReactElement { const hasAugment = Player.hasAugmentation(AugmentationName.TrickeryOfHermes, true); - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - if (code[index] !== getArrow(event)) { - props.onFailure(); - return; - } - setIndex(index + 1); - if (index + 1 >= code.length) props.onSuccess(); - } - return ( <> - Enter the Code! @@ -56,32 +22,20 @@ export function CheatCodeGame(props: IMinigameProps): React.ReactElement { style={{ display: "grid", gap: "2rem", - gridTemplateColumns: `repeat(${code.length}, 1fr)`, + gridTemplateColumns: `repeat(${stage.code.length}, 1fr)`, textAlign: "center", }} > - {code.map((arrow, i) => { + {stage.code.map((arrow, i) => { return ( - - {i > index && !hasAugment ? "?" : arrow} + + {i > stage.index && !hasAugment ? "?" : arrow} ); })} - ); } - -function generateCode(difficulty: Difficulty): Arrow[] { - const arrows: Arrow[] = [leftArrowSymbol, rightArrowSymbol, upArrowSymbol, downArrowSymbol]; - const code: Arrow[] = []; - for (let i = 0; i < randomInRange(difficulty.min, difficulty.max); i++) { - let arrow = arrows[Math.floor(4 * Math.random())]; - while (arrow === code[code.length - 1]) arrow = arrows[Math.floor(4 * Math.random())]; - code.push(arrow); - } - return code; -} diff --git a/src/Infiltration/ui/Countdown.tsx b/src/Infiltration/ui/Countdown.tsx index cb344b8190..bca5e576be 100644 --- a/src/Infiltration/ui/Countdown.tsx +++ b/src/Infiltration/ui/Countdown.tsx @@ -1,32 +1,18 @@ import { Paper, Typography } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { CountdownModel } from "../model/CountdownModel"; interface IProps { - onFinish: () => void; + state: Infiltration; + stage: CountdownModel; } -export function Countdown({ onFinish }: IProps): React.ReactElement { - const [x, setX] = useState(3); - - useEffect(() => { - if (x === 0) { - onFinish(); - } - }, [x, onFinish]); - - useEffect(() => { - const id = setInterval(() => { - setX((previousValue) => previousValue - 1); - }, 300); - return () => { - clearInterval(id); - }; - }, []); - +export function Countdown({ stage }: IProps): React.ReactElement { return ( Get Ready! - {x} + {stage.count} ); } diff --git a/src/Infiltration/ui/Cyberpunk2077Game.tsx b/src/Infiltration/ui/Cyberpunk2077Game.tsx index 7e5dbac5ed..915019953f 100644 --- a/src/Infiltration/ui/Cyberpunk2077Game.tsx +++ b/src/Infiltration/ui/Cyberpunk2077Game.tsx @@ -1,21 +1,14 @@ import { Paper, Typography, Box } from "@mui/material"; -import React, { useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { Cyberpunk2077Model } from "../model/Cyberpunk2077Model"; import { AugmentationName } from "@enums"; import { Player } from "@player"; import { Settings } from "../../Settings/Settings"; -import { KEY } from "../../utils/KeyboardEventKey"; -import { downArrowSymbol, getArrow, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -interface Difficulty { - [key: string]: number; - timer: number; - width: number; - height: number; - symbols: number; +interface IProps { + state: Infiltration; + stage: Cyberpunk2077Model; } interface GridItem { @@ -24,70 +17,16 @@ interface GridItem { selected?: boolean; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 12500, width: 3, height: 3, symbols: 6 }, - Normal: { timer: 15000, width: 4, height: 4, symbols: 7 }, - Hard: { timer: 12500, width: 5, height: 5, symbols: 8 }, - Brutal: { timer: 10000, width: 6, height: 6, symbols: 9 }, -}; - -export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, width: 0, height: 0, symbols: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [grid] = useState(generatePuzzle(difficulty)); - const [answers] = useState(generateAnswers(grid, difficulty)); - const [currentAnswerIndex, setCurrentAnswerIndex] = useState(0); - const [pos, setPos] = useState([0, 0]); - +export function Cyberpunk2077Game({ stage }: IProps): React.ReactElement { const hasAugment = Player.hasAugmentation(AugmentationName.FloodOfPoseidon, true); - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - const move = [0, 0]; - const arrow = getArrow(event); - switch (arrow) { - case upArrowSymbol: - move[1]--; - break; - case leftArrowSymbol: - move[0]--; - break; - case downArrowSymbol: - move[1]++; - break; - case rightArrowSymbol: - move[0]++; - break; - } - const next = [pos[0] + move[0], pos[1] + move[1]]; - next[0] = (next[0] + grid[0].length) % grid[0].length; - next[1] = (next[1] + grid.length) % grid.length; - setPos(next); - - if (event.key === KEY.SPACE) { - const selected = grid[pos[1]][pos[0]]; - const expected = answers[currentAnswerIndex]; - if (selected !== expected) { - props.onFailure(); - return; - } - setCurrentAnswerIndex(currentAnswerIndex + 1); - if (answers.length === currentAnswerIndex + 1) props.onSuccess(); - } - } const flatGrid: GridItem[] = []; - grid.map((line, y) => + stage.grid.map((line, y) => line.map((cell, x) => { - const isCorrectAnswer = cell === answers[currentAnswerIndex]; + const isCorrectAnswer = cell === stage.answers[stage.currentAnswerIndex]; const optionColor = hasAugment && !isCorrectAnswer ? Settings.theme.disabled : Settings.theme.primary; - if (x === pos[0] && y === pos[1]) { + if (x === stage.x && y === stage.y) { flatGrid.push({ color: optionColor, content: cell, selected: true }); return; } @@ -99,13 +38,12 @@ export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement { const fontSize = "2em"; return ( <> - Match the symbols! Targets:{" "} - {answers.map((a, i) => { - if (i == currentAnswerIndex) + {stage.answers.map((a, i) => { + if (i == stage.currentAnswerIndex) return ( {a}  @@ -122,7 +60,7 @@ export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement { @@ -141,32 +79,7 @@ export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement { ))} - ); } - -function generateAnswers(grid: string[][], difficulty: Difficulty): string[] { - const answers = []; - for (let i = 0; i < Math.round(difficulty.symbols); i++) { - answers.push(grid[Math.floor(Math.random() * grid.length)][Math.floor(Math.random() * grid[0].length)]); - } - return answers; -} - -function randChar(): string { - return "ABCDEF0123456789"[Math.floor(Math.random() * 16)]; -} - -function generatePuzzle(difficulty: Difficulty): string[][] { - const puzzle = []; - for (let i = 0; i < Math.round(difficulty.height); i++) { - const line = []; - for (let j = 0; j < Math.round(difficulty.width); j++) { - line.push(randChar() + randChar()); - } - puzzle.push(line); - } - return puzzle; -} diff --git a/src/Infiltration/ui/Difficulty.ts b/src/Infiltration/ui/Difficulty.ts deleted file mode 100644 index 1a284e4160..0000000000 --- a/src/Infiltration/ui/Difficulty.ts +++ /dev/null @@ -1,29 +0,0 @@ -type DifficultySetting = Record; - -interface DifficultySettings { - Trivial: DifficultySetting; - Normal: DifficultySetting; - Hard: DifficultySetting; - Brutal: DifficultySetting; -} - -// I could use `any` to simply some of this but I also want to take advantage -// of the type safety that typescript provides. I'm just not sure how in this -// case. -export function interpolate(settings: DifficultySettings, n: number, out: DifficultySetting): void { - // interpolates between 2 difficulties. - function lerpD(a: DifficultySetting, b: DifficultySetting, t: number): void { - // interpolates between 2 numbers. - function lerp(x: number, y: number, t: number): number { - return (1 - t) * x + t * y; - } - for (const key of Object.keys(a)) { - out[key] = lerp(a[key], b[key], t); - } - } - if (n < 0) return lerpD(settings.Trivial, settings.Trivial, 0); - if (n >= 0 && n < 1) return lerpD(settings.Trivial, settings.Normal, n); - if (n >= 1 && n < 2) return lerpD(settings.Normal, settings.Hard, n - 1); - if (n >= 2 && n < 3) return lerpD(settings.Hard, settings.Brutal, n - 2); - return lerpD(settings.Brutal, settings.Brutal, 0); -} diff --git a/src/Infiltration/ui/Game.tsx b/src/Infiltration/ui/Game.tsx deleted file mode 100644 index da238feda5..0000000000 --- a/src/Infiltration/ui/Game.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { Button, Container, Paper, Typography } from "@mui/material"; -import React, { useCallback, useEffect, useState } from "react"; -import { FactionName, ToastVariant } from "@enums"; -import { Router } from "../../ui/GameRoot"; -import { Page } from "../../ui/Router"; -import { Player } from "@player"; -import { BackwardGame } from "./BackwardGame"; -import { BracketGame } from "./BracketGame"; -import { BribeGame } from "./BribeGame"; -import { CheatCodeGame } from "./CheatCodeGame"; -import { Countdown } from "./Countdown"; -import { Cyberpunk2077Game } from "./Cyberpunk2077Game"; -import { MinesweeperGame } from "./MinesweeperGame"; -import { SlashGame } from "./SlashGame"; -import { Victory } from "./Victory"; -import { WireCuttingGame } from "./WireCuttingGame"; -import { calculateDamageAfterFailingInfiltration } from "../utils"; -import { SnackbarEvents } from "../../ui/React/Snackbar"; -import { PlayerEventType, PlayerEvents } from "../../PersonObjects/Player/PlayerEvents"; -import { dialogBoxCreate } from "../../ui/React/DialogBox"; -import { calculateReward, MaxDifficultyForInfiltration } from "../formulas/game"; - -type GameProps = { - startingSecurityLevel: number; - difficulty: number; - maxLevel: number; -}; - -enum Stage { - Countdown = 0, - Minigame, - Result, - Sell, -} - -const minigames = [ - SlashGame, - BracketGame, - BackwardGame, - BribeGame, - CheatCodeGame, - Cyberpunk2077Game, - MinesweeperGame, - WireCuttingGame, -]; - -export function Game({ startingSecurityLevel, difficulty, maxLevel }: GameProps): React.ReactElement { - const [level, setLevel] = useState(1); - const [stage, setStage] = useState(Stage.Countdown); - const [results, setResults] = useState(""); - const [gameIds, setGameIds] = useState({ - lastGames: [-1, -1], - id: Math.floor(Math.random() * minigames.length), - }); - // Base for when rewards are calculated, which is the start of the game window - const [timestamp, __] = useState(Date.now()); - const reward = calculateReward(startingSecurityLevel); - - const setupNextGame = useCallback(() => { - const nextGameId = () => { - let id = gameIds.lastGames[0]; - const ids = [gameIds.lastGames[0], gameIds.lastGames[1], gameIds.id]; - while (ids.includes(id)) { - id = Math.floor(Math.random() * minigames.length); - } - return id; - }; - - setGameIds({ - lastGames: [gameIds.lastGames[1], gameIds.id], - id: nextGameId(), - }); - }, [gameIds]); - - function pushResult(win: boolean): void { - setResults((old) => { - let next = old; - next += win ? "✓" : "✗"; - if (next.length > 15) next = next.slice(1); - return next; - }); - } - - const onSuccess = useCallback(() => { - pushResult(true); - if (level === maxLevel) { - setStage(Stage.Sell); - } else { - setStage(Stage.Countdown); - setLevel(level + 1); - } - setupNextGame(); - }, [level, maxLevel, setupNextGame]); - - const onFailure = useCallback( - (options?: { automated?: boolean; impossible?: boolean }) => { - setStage(Stage.Countdown); - pushResult(false); - Player.receiveRumor(FactionName.ShadowsOfAnarchy); - let damage = calculateDamageAfterFailingInfiltration(startingSecurityLevel); - // Kill the player immediately if they use automation, so it's clear they're not meant to - if (options?.automated) { - damage = Player.hp.current; - setTimeout(() => { - SnackbarEvents.emit( - "You were hospitalized. Do not try to automate infiltration!", - ToastVariant.WARNING, - 5000, - ); - }, 500); - } - if (options?.impossible) { - damage = Player.hp.current; - setTimeout(() => { - SnackbarEvents.emit( - "You were discovered immediately. That location is far too secure for your current skill level.", - ToastVariant.ERROR, - 5000, - ); - }, 500); - } - if (Player.takeDamage(damage)) { - Router.toPage(Page.City); - return; - } - setupNextGame(); - }, - [startingSecurityLevel, setupNextGame], - ); - - function cancel(): void { - Router.toPage(Page.City); - return; - } - - let stageComponent: React.ReactNode; - switch (stage) { - case Stage.Countdown: - stageComponent = setStage(Stage.Minigame)} />; - break; - case Stage.Minigame: { - const MiniGame = minigames[gameIds.id]; - stageComponent = ; - break; - } - case Stage.Sell: - stageComponent = ( - - ); - break; - } - - function Progress(): React.ReactElement { - return ( - - {results.slice(0, results.length - 1)} - {results[results.length - 1]} - - ); - } - - useEffect(() => { - const clearSubscription = PlayerEvents.subscribe((eventType) => { - if (eventType !== PlayerEventType.Hospitalized) { - return; - } - cancel(); - dialogBoxCreate("Infiltration was cancelled because you were hospitalized"); - }); - - return clearSubscription; - }, []); - - useEffect(() => { - // Immediately fail if the difficulty is higher than the max value. - if (difficulty >= MaxDifficultyForInfiltration) { - onFailure({ impossible: true }); - } - }); - - return ( - - - {stage !== Stage.Sell && ( - - )} - - Level {level} / {maxLevel} - - - - - {stageComponent} - - ); -} diff --git a/src/Infiltration/ui/GameTimer.tsx b/src/Infiltration/ui/GameTimer.tsx index 98e0818945..06b2f74884 100644 --- a/src/Infiltration/ui/GameTimer.tsx +++ b/src/Infiltration/ui/GameTimer.tsx @@ -1,54 +1,36 @@ -import { Paper } from "@mui/material"; -import React, { useEffect, useState } from "react"; -import { AugmentationName } from "@enums"; -import { Player } from "@player"; +import React, { useEffect, useRef, useMemo } from "react"; import { ProgressBar } from "../../ui/React/Progress"; type GameTimerProps = { - millis: number; - onExpire: () => void; - noPaper?: boolean; - ignoreAugment_WKSharmonizer?: boolean; - tick?: number; + endTimestamp: number; }; -export function GameTimer({ - millis, - onExpire, - noPaper, - ignoreAugment_WKSharmonizer, - tick = 100, -}: GameTimerProps): React.ReactElement { - const [v, setV] = useState(100); - const totalMillis = - (!ignoreAugment_WKSharmonizer && Player.hasAugmentation(AugmentationName.WKSharmonizer, true) ? 1.3 : 1) * millis; - - useEffect(() => { - if (v <= 0) { - onExpire(); - } - }, [v, onExpire]); - +export function GameTimer({ endTimestamp }: GameTimerProps): React.ReactElement { + // We need a stable DOM element to perform animations with. This is + // antithetical to the React philosophy, so things get a bit awkward. + const ref: React.Ref = useRef(null); + const changeRef = useRef({ startTimestamp: 0, endTimestamp: 0 }); + // Animation timing starts when we are asked to render, even though we can't + // actually begin it until later. We're using a ref to keep this updated in + // a very low-overhead way. + const state = changeRef.current; + if (state?.endTimestamp !== endTimestamp) { + state.endTimestamp = endTimestamp; + state.startTimestamp = performance.now(); + } useEffect(() => { - const intervalId = setInterval(() => { - setV((old) => { - return old - (tick / totalMillis) * 100; - }); - }, tick); - - return () => { - clearInterval(intervalId); - }; - }, [onExpire, tick, totalMillis]); - - // https://stackoverflow.com/questions/55593367/disable-material-uis-linearprogress-animation - // TODO(hydroflame): there's like a bug where it triggers the end before the - // bar physically reaches the end - return noPaper ? ( - - ) : ( - - - - ); + // All manipulation must be done in an effect, since this is after React's + // "commit," where the DOM is materialized. + const ele = ref.current?.firstElementChild as HTMLElement; + const startTimestamp = changeRef.current.startTimestamp; + if (!ele) return; + // The delay will be negative. This is because the animation starts + // partway completed, due to the time taken to invoke the effect. + ele.animate([{ transform: "translateX(0%)" }, { transform: "translateX(-100%)" }], { + duration: endTimestamp - startTimestamp, + delay: startTimestamp - performance.now(), + }); + }, [endTimestamp]); + // Never rerender the actual ProgressBar + return useMemo(() => , []); } diff --git a/src/Infiltration/ui/IMinigameProps.tsx b/src/Infiltration/ui/IMinigameProps.tsx deleted file mode 100644 index 7438378a02..0000000000 --- a/src/Infiltration/ui/IMinigameProps.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export interface IMinigameProps { - onSuccess: () => void; - onFailure: (options?: { - /** Failed due to using untrusted events (automation) */ - automated: boolean; - }) => void; - difficulty: number; -} diff --git a/src/Infiltration/ui/InfiltrationRoot.tsx b/src/Infiltration/ui/InfiltrationRoot.tsx index eb8ef56ece..7a44c2116a 100644 --- a/src/Infiltration/ui/InfiltrationRoot.tsx +++ b/src/Infiltration/ui/InfiltrationRoot.tsx @@ -1,57 +1,134 @@ -import React, { useState } from "react"; -import { Location } from "../../Locations/Location"; -import { Router } from "../../ui/GameRoot"; -import { Page } from "../../ui/Router"; -import { calculateDifficulty } from "../formulas/game"; -import { Game } from "./Game"; +import React, { useCallback, useEffect, useState } from "react"; +import type { InfiltrationStage } from "../InfiltrationStage"; +import type { Infiltration } from "../Infiltration"; +import { Player } from "@player"; +import { Button, Container, Paper, Typography } from "@mui/material"; +import { GameTimer } from "./GameTimer"; import { Intro } from "./Intro"; -import { dialogBoxCreate } from "../../ui/React/DialogBox"; +import { IntroModel } from "../model/IntroModel"; +import { Countdown } from "./Countdown"; +import { CountdownModel } from "../model/CountdownModel"; +import { BackwardGame } from "./BackwardGame"; +import { BackwardModel } from "../model/BackwardModel"; +import { BracketGame } from "./BracketGame"; +import { BracketModel } from "../model/BracketModel"; +import { BribeGame } from "./BribeGame"; +import { BribeModel } from "../model/BribeModel"; +import { CheatCodeGame } from "./CheatCodeGame"; +import { CheatCodeModel } from "../model/CheatCodeModel"; +import { Cyberpunk2077Game } from "./Cyberpunk2077Game"; +import { Cyberpunk2077Model } from "../model/Cyberpunk2077Model"; +import { MinesweeperGame } from "./MinesweeperGame"; +import { MinesweeperModel } from "../model/MinesweeperModel"; +import { SlashGame } from "./SlashGame"; +import { SlashModel } from "../model/SlashModel"; +import { WireCuttingGame } from "./WireCuttingGame"; +import { WireCuttingModel } from "../model/WireCuttingModel"; +import { Victory } from "./Victory"; +import { VictoryModel } from "../model/VictoryModel"; -interface IProps { - location: Location; +interface StageProps { + state: Infiltration; + stage: InfiltrationStage; } -export function InfiltrationRoot(props: IProps): React.ReactElement { - const [start, setStart] = useState(false); - - if (!props.location.infiltrationData) { - /** - * Using setTimeout is unnecessary, because we can just call cancel() and dialogBoxCreate(). However, without - * setTimeout, we will go to City page (in "cancel" function) and update GameRoot while still rendering - * InfiltrationRoot. React will complain: "Warning: Cannot update a component (`GameRoot`) while rendering a - * different component (`InfiltrationRoot`)". - */ - setTimeout(() => { - cancel(); - dialogBoxCreate(`You tried to infiltrate an invalid location: ${props.location.name}`); - }, 100); - return <>; - } +// The extra cast here is needed because otherwise it sees the more-specific +// types of the components, and gets grumpy that they are not interconvertable. +const stages = new Map([ + [IntroModel, Intro], + [CountdownModel, Countdown], + [BackwardModel, BackwardGame], + [BracketModel, BracketGame], + [BribeModel, BribeGame], + [CheatCodeModel, CheatCodeGame], + [Cyberpunk2077Model, Cyberpunk2077Game], + [MinesweeperModel, MinesweeperGame], + [SlashModel, SlashGame], + [WireCuttingModel, WireCuttingGame], + [VictoryModel, Victory], +] as [new () => object, React.ComponentType][]); - const startingSecurityLevel = props.location.infiltrationData.startingSecurityLevel; - const difficulty = calculateDifficulty(startingSecurityLevel); +function Progress({ results }: { results: string }): React.ReactElement { + return ( + + {results.slice(-15, -1)} + {results[results.length - 1]} + + ); +} - function cancel(): void { - Router.toPage(Page.City); - } +export function InfiltrationRoot(): React.ReactElement { + const state = Player.infiltration; + const [__, setRefresh] = useState(0); + const cancel = useCallback(() => state?.cancel?.(), [state]); + // As a precaution, tear down infil if we leave the page. This covers us + // from things like Singularity changing pages. + useEffect(() => cancel, [cancel]); + useEffect(() => { + if (!state) { + return; + } + const press = (event: KeyboardEvent) => { + if (!event.isTrusted || !(event instanceof KeyboardEvent)) { + state.onFailure({ automated: true }); + return; + } + // A slight sublety here: This dispatches events to the currently active + // stage, not to the stage corresponding to the currently displayed UI. + // The two should generally be the same, but since React does async + // stuff, it can lag behind (potentially a lot, in edge cases). + state.stage.onKey(event); + }; + const unsub = state.updateEvent.subscribe(() => setRefresh((old) => old + 1)); + document.addEventListener("keydown", press); + return () => { + unsub(); + document.removeEventListener("keydown", press); + }; + }, [state]); + + if (!state) { + // This shouldn't happen, but we can't completely rule it out due to React + // timing weirdness. Show a basic message in case players actually see + // this. Because the current page is not saved, reloading should always + // fix this state. + return ( +
+ Not currently infiltrating! +
+ ); + } + const StageComponent = stages.get(state.stage.constructor as new () => object); + if (!StageComponent) { + throw new Error("Internal error: Unknown stage " + state.stage.constructor.name); + } return (
- {start ? ( - + {state.stage instanceof IntroModel ? ( + ) : ( - setStart(true)} - cancel={cancel} - /> + + + {state.stage instanceof VictoryModel || ( + + )} + + Level {state.level} / {state.maxLevel} + + + + + {state.stage instanceof CountdownModel || state.stage instanceof VictoryModel || ( + + + + )} + + + )}
); diff --git a/src/Infiltration/ui/Intro.tsx b/src/Infiltration/ui/Intro.tsx index e4eb4921ec..8d82428031 100644 --- a/src/Infiltration/ui/Intro.tsx +++ b/src/Infiltration/ui/Intro.tsx @@ -1,9 +1,10 @@ import { Box, Button, Container, Paper, Typography } from "@mui/material"; -import React from "react"; -import type { Location } from "../../Locations/Location"; +import React, { useCallback } from "react"; import { Settings } from "../../Settings/Settings"; import { formatHp, formatMoney, formatNumberNoSuffix, formatPercent, formatReputation } from "../../ui/formatNumber"; import { Player } from "@player"; +import type { Infiltration } from "../Infiltration"; +import type { IntroModel } from "../model/IntroModel"; import { calculateDamageAfterFailingInfiltration } from "../utils"; import { calculateInfiltratorsRepReward, @@ -16,12 +17,8 @@ import { calculateMarketDemandMultiplier, calculateReward, MaxDifficultyForInfil import { useRerender } from "../../ui/React/hooks"; interface IProps { - location: Location; - startingSecurityLevel: number; - difficulty: number; - maxLevel: number; - start: () => void; - cancel: () => void; + state: Infiltration; + stage: IntroModel; } function arrowPart(color: string, length: number): JSX.Element { @@ -62,39 +59,45 @@ function coloredArrow(difficulty: number): JSX.Element { } } -export function Intro({ - location, - startingSecurityLevel, - difficulty, - maxLevel, - start, - cancel, -}: IProps): React.ReactElement { +export function Intro({ state }: IProps): React.ReactElement { + // We need to rerender ourselves based on things that change that aren't + // reflected in Infiltration itself. useRerender(1000); const timestampNow = Date.now(); - const reward = calculateReward(startingSecurityLevel); - const repGain = calculateTradeInformationRepReward(reward, maxLevel, startingSecurityLevel, timestampNow); - const moneyGain = calculateSellInformationCashReward(reward, maxLevel, startingSecurityLevel, timestampNow); + const reward = calculateReward(state.startingSecurityLevel); + const repGain = calculateTradeInformationRepReward(reward, state.maxLevel, state.startingSecurityLevel, timestampNow); + const moneyGain = calculateSellInformationCashReward( + reward, + state.maxLevel, + state.startingSecurityLevel, + timestampNow, + ); const soaRepGain = calculateInfiltratorsRepReward( Factions[FactionName.ShadowsOfAnarchy], - maxLevel, - startingSecurityLevel, + state.maxLevel, + state.startingSecurityLevel, timestampNow, ); const marketRateMultiplier = calculateMarketDemandMultiplier(timestampNow, false); + const start = useCallback(() => state.startInfiltration(), [state]); + const cancel = useCallback(() => state.cancel(), [state]); + let warningMessage; - if (difficulty >= MaxDifficultyForInfiltration) { + if (state.startingDifficulty >= MaxDifficultyForInfiltration) { warningMessage = ( This location is too secure for your current abilities. You cannot infiltrate it. ); - } else if (difficulty >= 1.5) { + } else if (state.startingDifficulty >= 1.5) { warningMessage = ( - 2 ? Settings.theme.error : Settings.theme.warning} textAlign="center"> + 2 ? Settings.theme.error : Settings.theme.warning} + textAlign="center" + > This location is too heavily guarded for your current stats. You should train more or find an easier location. ); @@ -104,19 +107,21 @@ export function Intro({ - Infiltrating {location.name} + Infiltrating {state.location.name} HP: {`${formatHp(Player.hp.current)} / ${formatHp(Player.hp.max)}`} - Lose {formatHp(calculateDamageAfterFailingInfiltration(startingSecurityLevel))} HP for each failure + + Lose {formatHp(calculateDamageAfterFailingInfiltration(state.startingSecurityLevel))} HP for each failure + Maximum clearance level: - {maxLevel} + {state.maxLevel}
@@ -143,15 +148,21 @@ export function Intro({ variant="h6" sx={{ color: - difficulty > 2 ? Settings.theme.error : difficulty > 1 ? Settings.theme.warning : Settings.theme.primary, + state.startingDifficulty > 2 + ? Settings.theme.error + : state.startingDifficulty > 1 + ? Settings.theme.warning + : Settings.theme.primary, display: "flex", alignItems: "center", }} > Difficulty:  - {formatNumberNoSuffix(difficulty * (100 / MaxDifficultyForInfiltration))} / 100 + {formatNumberNoSuffix(state.startingDifficulty * (100 / MaxDifficultyForInfiltration))} / 100 +
+ + [{coloredArrow(state.startingDifficulty)}] - [{coloredArrow(difficulty)}] {`▲ ▲ ▲ ▲ ▲`} @@ -194,7 +205,7 @@ export function Intro({ - diff --git a/src/Infiltration/ui/KeyHandler.tsx b/src/Infiltration/ui/KeyHandler.tsx deleted file mode 100644 index 3ea72084d4..0000000000 --- a/src/Infiltration/ui/KeyHandler.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useEffect } from "react"; - -interface IProps { - onKeyDown: (event: KeyboardEvent) => void; - onFailure: (options?: { automated: boolean }) => void; -} - -export function KeyHandler(props: IProps): React.ReactElement { - useEffect(() => { - function press(event: KeyboardEvent): void { - if (!event.isTrusted || !(event instanceof KeyboardEvent)) { - props.onFailure({ automated: true }); - return; - } - props.onKeyDown(event); - } - document.addEventListener("keydown", press); - return () => document.removeEventListener("keydown", press); - }); - - // invisible autofocused element that eats all the keypress for the minigames. - return <>; -} diff --git a/src/Infiltration/ui/MinesweeperGame.tsx b/src/Infiltration/ui/MinesweeperGame.tsx index 1efc00adea..8d61bd28c6 100644 --- a/src/Infiltration/ui/MinesweeperGame.tsx +++ b/src/Infiltration/ui/MinesweeperGame.tsx @@ -1,100 +1,32 @@ import { Close, Flag, Report } from "@mui/icons-material"; import { Box, Paper, Typography } from "@mui/material"; -import { uniqueId } from "lodash"; -import React, { useEffect, useState } from "react"; +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { MinesweeperModel } from "../model/MinesweeperModel"; import { AugmentationName } from "@enums"; import { Player } from "@player"; import { Settings } from "../../Settings/Settings"; -import { KEY } from "../../utils/KeyboardEventKey"; -import { downArrowSymbol, getArrow, leftArrowSymbol, rightArrowSymbol, upArrowSymbol } from "../utils"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -interface Difficulty { - [key: string]: number; - timer: number; - width: number; - height: number; - mines: number; +interface IProps { + state: Infiltration; + stage: MinesweeperModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 15000, width: 3, height: 3, mines: 4 }, - Normal: { timer: 15000, width: 4, height: 4, mines: 7 }, - Hard: { timer: 15000, width: 5, height: 5, mines: 11 }, - Brutal: { timer: 15000, width: 6, height: 6, mines: 15 }, -}; - -export function MinesweeperGame(props: IMinigameProps): React.ReactElement { - const difficulty: Difficulty = { timer: 0, width: 0, height: 0, mines: 0 }; - interpolate(difficulties, props.difficulty, difficulty); - const timer = difficulty.timer; - const [minefield] = useState(generateMinefield(difficulty)); - const [answer, setAnswer] = useState(generateEmptyField(difficulty)); - const [pos, setPos] = useState([0, 0]); - const [memoryPhase, setMemoryPhase] = useState(true); +export function MinesweeperGame({ stage }: IProps): React.ReactElement { const hasAugment = Player.hasAugmentation(AugmentationName.HuntOfArtemis, true); - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - if (memoryPhase) return; - const move = [0, 0]; - const arrow = getArrow(event); - switch (arrow) { - case upArrowSymbol: - move[1]--; - break; - case leftArrowSymbol: - move[0]--; - break; - case downArrowSymbol: - move[1]++; - break; - case rightArrowSymbol: - move[0]++; - break; - } - const next = [pos[0] + move[0], pos[1] + move[1]]; - next[0] = (next[0] + minefield[0].length) % minefield[0].length; - next[1] = (next[1] + minefield.length) % minefield.length; - setPos(next); - - if (event.key == KEY.SPACE) { - if (!minefield[pos[1]][pos[0]]) { - props.onFailure(); - return; - } - setAnswer((old) => { - old[pos[1]][pos[0]] = true; - if (fieldEquals(minefield, old)) props.onSuccess(); - return old; - }); - } - } - - useEffect(() => { - const id = setTimeout(() => setMemoryPhase(false), 2000); - return () => clearInterval(id); - }, []); const flatGrid: { flagged?: boolean; current?: boolean; marked?: boolean }[] = []; - minefield.map((line, y) => + stage.minefield.map((line, y) => line.map((cell, x) => { - if (memoryPhase) { - flatGrid.push({ flagged: Boolean(minefield[y][x]) }); + if (stage.memoryPhase) { + flatGrid.push({ flagged: Boolean(stage.minefield[y][x]) }); return; - } else if (x === pos[0] && y === pos[1]) { + } else if (x === stage.x && y === stage.y) { flatGrid.push({ current: true }); - } else if (answer[y][x]) { + } else if (stage.answer[y][x]) { flatGrid.push({ marked: true }); - } else if (hasAugment && minefield[y][x]) { + } else if (hasAugment && stage.minefield[y][x]) { flatGrid.push({ flagged: true }); } else { flatGrid.push({}); @@ -104,18 +36,17 @@ export function MinesweeperGame(props: IMinigameProps): React.ReactElement { return ( <> - - {memoryPhase ? "Remember all the mines!" : "Mark all the mines!"} + {stage.memoryPhase ? "Remember all the mines!" : "Mark all the mines!"} - {flatGrid.map((item) => { + {flatGrid.map((item, i) => { let color: string; let icon: React.ReactElement; @@ -135,7 +66,7 @@ export function MinesweeperGame(props: IMinigameProps): React.ReactElement { return ( - ); } - -function fieldEquals(a: boolean[][], b: boolean[][]): boolean { - function count(field: boolean[][]): number { - return field.flat().reduce((a, b) => a + (b ? 1 : 0), 0); - } - return count(a) === count(b); -} - -function generateEmptyField(difficulty: Difficulty): boolean[][] { - const field: boolean[][] = []; - for (let i = 0; i < Math.round(difficulty.height); i++) { - field.push(new Array(Math.round(difficulty.width)).fill(false)); - } - return field; -} - -function generateMinefield(difficulty: Difficulty): boolean[][] { - const field = generateEmptyField(difficulty); - for (let i = 0; i < Math.round(difficulty.mines); i++) { - const x = Math.floor(Math.random() * field.length); - const y = Math.floor(Math.random() * field[0].length); - if (field[x][y]) { - i--; - continue; - } - field[x][y] = true; - } - return field; -} diff --git a/src/Infiltration/ui/SlashGame.tsx b/src/Infiltration/ui/SlashGame.tsx index a0e131c1a3..a6eeb89f5f 100644 --- a/src/Infiltration/ui/SlashGame.tsx +++ b/src/Infiltration/ui/SlashGame.tsx @@ -1,93 +1,17 @@ import { Box, Paper, Typography } from "@mui/material"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AugmentationName } from "@enums"; -import { Player } from "@player"; -import { KEY } from "../../utils/KeyboardEventKey"; -import { interpolate } from "./Difficulty"; +import React from "react"; import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; +import type { Infiltration } from "../Infiltration"; +import type { SlashModel } from "../model/SlashModel"; -interface Difficulty { - [key: string]: number; - window: number; +interface IProps { + state: Infiltration; + stage: SlashModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { window: 800 }, - Normal: { window: 500 }, - Hard: { window: 350 }, - Brutal: { window: 250 }, -}; - -export function SlashGame({ difficulty, onSuccess, onFailure }: IMinigameProps): React.ReactElement { - const [phase, setPhase] = useState(0); - const timeOutId = useRef>(-1); - const hasWKSharmonizer = Player.hasAugmentation(AugmentationName.WKSharmonizer, true); - const hasMightOfAres = Player.hasAugmentation(AugmentationName.MightOfAres, true); - - const data = useMemo(() => { - // Determine time window of phases - const newDifficulty: Difficulty = { window: 0 }; - interpolate(difficulties, difficulty, newDifficulty); - const distractedTime = newDifficulty.window * (hasWKSharmonizer ? 1.3 : 1); - const alertedTime = 250; - const guardingTime = Math.random() * 3250 + 1500 - (distractedTime + alertedTime); - - return { - hasAugment: hasMightOfAres, - guardingTime, - distractedTime, - alertedTime, - }; - }, [difficulty, hasWKSharmonizer, hasMightOfAres]); - - useEffect(() => { - return () => { - if (timeOutId.current !== -1) { - clearTimeout(timeOutId.current); - } - }; - }, []); - - const startPhase1 = useCallback( - (alertedTime: number, distractedTime: number) => { - setPhase(1); - timeOutId.current = setTimeout(() => { - setPhase(2); - timeOutId.current = setTimeout(() => onFailure(), alertedTime); - }, distractedTime); - }, - [onFailure], - ); - - useEffect(() => { - // Start the timer if the player does not have MightOfAres augmentation. - if (phase === 0 && !data.hasAugment) { - timeOutId.current = setTimeout(() => { - startPhase1(data.alertedTime, data.distractedTime); - }, data.guardingTime); - } - }, [phase, data, startPhase1]); - - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - if (event.key !== KEY.SPACE) return; - if (phase !== 1) { - onFailure(); - } else { - onSuccess(); - } - } - +export function SlashGame({ stage }: IProps): React.ReactElement { return ( <> - Attack after the sentinel drops his guard and is distracted. @@ -95,26 +19,17 @@ export function SlashGame({ difficulty, onSuccess, onFailure }: IMinigameProps): Do not alert him!
- {phase === 0 && data.hasAugment && ( + {stage.phase === 0 && stage.hasMightOfAres && ( The sentinel will drop his guard and be distracted in ... - { - startPhase1(data.alertedTime, data.distractedTime); - }} - ignoreAugment_WKSharmonizer - noPaper - tick={20} - /> +
)} - {phase === 0 && Guarding ...} - {phase === 1 && Distracted!} - {phase === 2 && Alerted!} - + {stage.phase === 0 && Guarding ...} + {stage.phase === 1 && Distracted!} + {stage.phase === 2 && Alerted!}
); diff --git a/src/Infiltration/ui/Victory.tsx b/src/Infiltration/ui/Victory.tsx index 8e746d691e..a35691f6f6 100644 --- a/src/Infiltration/ui/Victory.tsx +++ b/src/Infiltration/ui/Victory.tsx @@ -4,10 +4,10 @@ import { Box, Button, MenuItem, Paper, Select, SelectChangeEvent, Typography } f import { Player } from "@player"; import { FactionName } from "@enums"; +import type { Infiltration } from "../Infiltration"; +import type { VictoryModel } from "../model/VictoryModel"; import { inviteToFaction } from "../../Faction/FactionHelpers"; import { Factions } from "../../Faction/Factions"; -import { Router } from "../../ui/GameRoot"; -import { Page } from "../../ui/Router"; import { Money } from "../../ui/React/Money"; import { Reputation } from "../../ui/React/Reputation"; import { formatNumberNoSuffix } from "../../ui/formatNumber"; @@ -18,20 +18,17 @@ import { } from "../formulas/victory"; import { getEnumHelper } from "../../utils/EnumHelper"; import { isFactionWork } from "../../Work/FactionWork"; -import { decreaseMarketDemandMultiplier } from "../formulas/game"; +import { calculateReward, decreaseMarketDemandMultiplier } from "../formulas/game"; interface IProps { - startingSecurityLevel: number; - difficulty: number; - reward: number; - timestamp: number; - maxLevel: number; + state: Infiltration; + stage: VictoryModel; } // Use a module-scope variable to save the faction choice. let defaultFactionChoice: FactionName | "none" = "none"; -export function Victory(props: IProps): React.ReactElement { +export function Victory({ state }: IProps): React.ReactElement { /** * Use the working faction as the default choice in 2 cases: * - The player has not chosen a faction. @@ -44,28 +41,29 @@ export function Victory(props: IProps): React.ReactElement { function quitInfiltration(): void { handleInfiltrators(); - decreaseMarketDemandMultiplier(props.timestamp, props.maxLevel); - Router.toPage(Page.City); + decreaseMarketDemandMultiplier(state.gameStartTimestamp, state.maxLevel); + state.cancel(); } const soa = Factions[FactionName.ShadowsOfAnarchy]; + const reward = calculateReward(state.startingSecurityLevel); const repGain = calculateTradeInformationRepReward( - props.reward, - props.maxLevel, - props.startingSecurityLevel, - props.timestamp, + reward, + state.maxLevel, + state.startingSecurityLevel, + state.gameStartTimestamp, ); const moneyGain = calculateSellInformationCashReward( - props.reward, - props.maxLevel, - props.startingSecurityLevel, - props.timestamp, + reward, + state.maxLevel, + state.startingSecurityLevel, + state.gameStartTimestamp, ); const infiltrationRepGain = calculateInfiltratorsRepReward( soa, - props.maxLevel, - props.startingSecurityLevel, - props.timestamp, + state.maxLevel, + state.startingSecurityLevel, + state.gameStartTimestamp, ); const isMemberOfInfiltrators = Player.factions.includes(FactionName.ShadowsOfAnarchy); diff --git a/src/Infiltration/ui/WireCuttingGame.tsx b/src/Infiltration/ui/WireCuttingGame.tsx index f729db5f8c..997f13aeb7 100644 --- a/src/Infiltration/ui/WireCuttingGame.tsx +++ b/src/Infiltration/ui/WireCuttingGame.tsx @@ -1,137 +1,37 @@ -import React, { useEffect, useState } from "react"; - +import React from "react"; +import type { Infiltration } from "../Infiltration"; +import type { WireCuttingModel } from "../model/WireCuttingModel"; import { Box, Paper, Typography } from "@mui/material"; import { AugmentationName } from "@enums"; import { Player } from "@player"; import { Settings } from "../../Settings/Settings"; -import { interpolate } from "./Difficulty"; -import { GameTimer } from "./GameTimer"; -import { IMinigameProps } from "./IMinigameProps"; -import { KeyHandler } from "./KeyHandler"; -import { isPositiveInteger } from "../../types"; -import { randomInRange } from "../../utils/helpers/randomInRange"; -interface Difficulty { - [key: string]: number; - timer: number; - wiresmin: number; - wiresmax: number; - rules: number; +interface IProps { + state: Infiltration; + stage: WireCuttingModel; } -const difficulties: { - Trivial: Difficulty; - Normal: Difficulty; - Hard: Difficulty; - Brutal: Difficulty; -} = { - Trivial: { timer: 9000, wiresmin: 4, wiresmax: 4, rules: 2 }, - Normal: { timer: 7000, wiresmin: 6, wiresmax: 6, rules: 2 }, - Hard: { timer: 5000, wiresmin: 8, wiresmax: 8, rules: 3 }, - Brutal: { timer: 4000, wiresmin: 9, wiresmax: 9, rules: 4 }, -}; - -const colors = ["red", "#FFC107", "blue", "white"]; - -const colorNames: Record = { - red: "RED", - "#FFC107": "YELLOW", - blue: "BLUE", - white: "WHITE", -}; - -interface Wire { - wireType: string[]; - colors: string[]; -} - -interface Question { - toString: () => string; - shouldCut: (wire: Wire, index: number) => boolean; -} - -export function WireCuttingGame({ onSuccess, onFailure, difficulty }: IMinigameProps): React.ReactElement { - const [questions, setQuestions] = useState([]); - const [wires, setWires] = useState([]); - const [timer, setTimer] = useState(0); - const [cutWires, setCutWires] = useState([]); - const [wiresToCut, setWiresToCut] = useState(new Set()); - const [hasAugment, setHasAugment] = useState(false); - - useEffect(() => { - // Determine game difficulty - const gameDifficulty: Difficulty = { - timer: 0, - wiresmin: 0, - wiresmax: 0, - rules: 0, - }; - interpolate(difficulties, difficulty, gameDifficulty); - - // Calculate initial game data - const gameWires = generateWires(gameDifficulty); - const gameQuestions = generateQuestion(gameWires, gameDifficulty); - const gameWiresToCut = new Set(); - gameWires.forEach((wire, index) => { - for (const question of gameQuestions) { - if (question.shouldCut(wire, index)) { - gameWiresToCut.add(index); - return; // go to next wire - } - } - }); - - // Initialize the game state - setTimer(gameDifficulty.timer); - setWires(gameWires); - setCutWires(gameWires.map((__) => false)); - setQuestions(gameQuestions); - setWiresToCut(gameWiresToCut); - setHasAugment(Player.hasAugmentation(AugmentationName.KnowledgeOfApollo, true)); - }, [difficulty]); - - function press(this: Document, event: KeyboardEvent): void { - event.preventDefault(); - const wireNum = parseInt(event.key); - if (!isPositiveInteger(wireNum) || wireNum > wires.length) return; - - const wireIndex = wireNum - 1; - if (cutWires[wireIndex]) return; - - // Check if game has been lost - if (!wiresToCut.has(wireIndex)) return onFailure(); - - // Check if game has been won - const newWiresToCut = new Set(wiresToCut); - newWiresToCut.delete(wireIndex); - if (newWiresToCut.size === 0) return onSuccess(); - - // Rerender with new state if game has not been won or lost yet - const newCutWires = cutWires.map((old, i) => (i === wireIndex ? true : old)); - setWiresToCut(newWiresToCut); - setCutWires(newCutWires); - } - +export function WireCuttingGame({ stage }: IProps): React.ReactElement { + const hasAugment = Player.hasAugmentation(AugmentationName.KnowledgeOfApollo, true); return ( <> - Cut the wires with the following properties! (keyboard 1 to 9) - {questions.map((question, i) => ( + {stage.questions.map((question, i) => ( {question.toString()} ))} - {Array.from({ length: wires.length }).map((_, i) => { - const isCorrectWire = cutWires[i] || wiresToCut.has(i); + {Array.from({ length: stage.wires.length }, (_, i) => { + const isCorrectWire = stage.cutWires[i] || stage.wiresToCut.has(i); const color = hasAugment && !isCorrectWire ? Settings.theme.disabled : Settings.theme.primary; return ( @@ -139,13 +39,13 @@ export function WireCuttingGame({ onSuccess, onFailure, difficulty }: IMinigameP ); })} - {new Array(11).fill(0).map((_, i) => ( + {Array.from({ length: 11 }, (_, i) => ( - {wires.map((wire, j) => { - if ((i === 3 || i === 4) && cutWires[j]) { + {stage.wires.map((wire, j) => { + if ((i === 3 || i === 4) && stage.cutWires[j]) { return ; } - const isCorrectWire = cutWires[j] || wiresToCut.has(j); + const isCorrectWire = stage.cutWires[j] || stage.wiresToCut.has(j); const wireColor = hasAugment && !isCorrectWire ? Settings.theme.disabled : wire.colors[i % wire.colors.length]; return ( @@ -157,60 +57,7 @@ export function WireCuttingGame({ onSuccess, onFailure, difficulty }: IMinigameP ))} - ); } - -function randomPositionQuestion(wires: Wire[]): Question { - const index = Math.floor(Math.random() * wires.length); - return { - toString: (): string => { - return `Cut wires number ${index + 1}.`; - }, - shouldCut: (_wire: Wire, i: number): boolean => { - return index === i; - }, - }; -} - -function randomColorQuestion(wires: Wire[]): Question { - const index = Math.floor(Math.random() * wires.length); - const cutColor = wires[index].colors[0]; - return { - toString: (): string => { - return `Cut all wires colored ${colorNames[cutColor]}.`; - }, - shouldCut: (wire: Wire): boolean => { - return wire.colors.includes(cutColor); - }, - }; -} - -function generateQuestion(wires: Wire[], difficulty: Difficulty): Question[] { - const numQuestions = difficulty.rules; - const questionGenerators = [randomPositionQuestion, randomColorQuestion]; - const questions = []; - for (let i = 0; i < numQuestions; i++) { - questions.push(questionGenerators[i % 2](wires)); - } - return questions; -} - -function generateWires(difficulty: Difficulty): Wire[] { - const wires = []; - const numWires = randomInRange(difficulty.wiresmin, difficulty.wiresmax); - for (let i = 0; i < numWires; i++) { - const wireColors = [colors[Math.floor(Math.random() * colors.length)]]; - if (Math.random() < 0.15) { - wireColors.push(colors[Math.floor(Math.random() * colors.length)]); - } - const wireType = [...wireColors.map((color) => colorNames[color]).join("")]; - wires.push({ - wireType, - colors: wireColors, - }); - } - return wires; -} diff --git a/src/Infiltration/utils.ts b/src/Infiltration/utils.ts index 05542b89ed..81a33251b6 100644 --- a/src/Infiltration/utils.ts +++ b/src/Infiltration/utils.ts @@ -1,3 +1,4 @@ +import type { KeyboardLikeEvent } from "./InfiltrationStage"; import { KEY } from "../utils/KeyboardEventKey"; import { Player } from "@player"; import { AugmentationName } from "@enums"; @@ -9,7 +10,7 @@ export const rightArrowSymbol = "→"; export type Arrow = typeof leftArrowSymbol | typeof rightArrowSymbol | typeof upArrowSymbol | typeof downArrowSymbol; -export function getArrow(event: KeyboardEvent): Arrow | undefined { +export function getArrow(event: KeyboardLikeEvent): Arrow | undefined { switch (event.key) { case KEY.UP_ARROW: case KEY.W: diff --git a/src/Locations/ui/CompanyLocation.tsx b/src/Locations/ui/CompanyLocation.tsx index 62091185ba..2b6b155567 100644 --- a/src/Locations/ui/CompanyLocation.tsx +++ b/src/Locations/ui/CompanyLocation.tsx @@ -62,10 +62,9 @@ export function CompanyLocation(props: IProps): React.ReactElement { if (!e.isTrusted) { return; } - if (!location.infiltrationData) - throw new Error(`trying to start infiltration at ${props.companyName} but the infiltrationData is null`); - Router.toPage(Page.Infiltration, { location }); + Player.startInfiltration(location); + Router.toPage(Page.Infiltration); } function work(e: React.MouseEvent): void { diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index 21ec07439f..70d18aaab4 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -2,6 +2,7 @@ import type { BitNodeOptions, Player as IPlayer } from "@nsdefs"; import type { PlayerAchievement } from "../../Achievements/Achievements"; import type { Bladeburner } from "../../Bladeburner/Bladeburner"; import type { Corporation } from "../../Corporation/Corporation"; +import type { Infiltration } from "../../Infiltration/Infiltration"; import type { Exploit } from "../../Exploits/Exploit"; import type { Gang } from "../../Gang/Gang"; import type { HacknetNode } from "../../Hacknet/HacknetNode"; @@ -24,6 +25,7 @@ import { constructorsForReviver, Generic_toJSON, Generic_fromJSON, IReviverValue import { JSONMap, JSONSet } from "../../Types/Jsonable"; import { cyrb53 } from "../../utils/HashUtils"; import { getRandomIntInclusive } from "../../utils/helpers/getRandomIntInclusive"; +import { getKeyList } from "../../utils/helpers/getKeyList"; import { CONSTANTS } from "../../Constants"; import { Person } from "../Person"; import { isMember } from "../../utils/EnumHelper"; @@ -36,6 +38,7 @@ export class PlayerObject extends Person implements IPlayer { corporation: Corporation | null = null; gang: Gang | null = null; bladeburner: Bladeburner | null = null; + infiltration: Infiltration | null = null; currentServer = ""; factions: FactionName[] = []; factionInvitations: FactionName[] = []; @@ -149,6 +152,7 @@ export class PlayerObject extends Person implements IPlayer { activeSourceFileLvl = generalMethods.activeSourceFileLvl; applyEntropy = augmentationMethods.applyEntropy; focusPenalty = generalMethods.focusPenalty; + startInfiltration = generalMethods.startInfiltration; constructor() { super(); @@ -178,7 +182,8 @@ export class PlayerObject extends Person implements IPlayer { /** Serialize the current object to a JSON save state. */ toJSON(): IReviverValue { - return Generic_toJSON("PlayerObject", this); + // For the time being, infiltration is not part of the save. + return Generic_toJSON("PlayerObject", this, getKeyList(PlayerObject, { removedKeys: ["infiltration"] })); } /** Initializes a PlayerObject object from a JSON save state. */ diff --git a/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts b/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts index a6300a0800..d87b0cc56e 100644 --- a/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts +++ b/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts @@ -33,6 +33,7 @@ import { SleeveWorkType } from "../Sleeve/Work/Work"; import { calculateSkillProgress as calculateSkillProgressF, ISkillProgress } from "../formulas/skill"; import { AddToAllServers, createUniqueRandomIp } from "../../Server/AllServers"; import { safelyCreateUniqueServer } from "../../Server/ServerHelpers"; +import { Location } from "../../Locations/Location"; import { SpecialServers } from "../../Server/data/SpecialServers"; import { applySourceFile } from "../../SourceFile/applySourceFile"; @@ -55,6 +56,7 @@ import { Augmentations } from "../../Augmentation/Augmentations"; import { PlayerEventType, PlayerEvents } from "./PlayerEvents"; import { Result } from "../../types"; import type { AchievementId } from "../../Achievements/Types"; +import { Infiltration } from "../../Infiltration/Infiltration"; export function init(this: PlayerObject): void { /* Initialize Player's home computer */ @@ -599,3 +601,10 @@ export function focusPenalty(this: PlayerObject): number { } return focus; } + +/** This doesn't change the current page; that is up to the caller. */ +export function startInfiltration(this: PlayerObject, location: Location): void { + if (!location.infiltrationData) + throw new Error(`trying to start infiltration at ${location.name} but the infiltrationData is null`); + this.infiltration = new Infiltration(location); +} diff --git a/src/ui/Enums.ts b/src/ui/Enums.ts index ff2576cd6f..e6d173959a 100644 --- a/src/ui/Enums.ts +++ b/src/ui/Enums.ts @@ -25,6 +25,7 @@ export enum SimplePage { Gang = "Gang", Go = "IPvGO Subnet", Hacknet = "Hacknet", + Infiltration = "Infiltration", Milestones = "Milestones", Options = "Options", Grafting = "Grafting", @@ -45,7 +46,6 @@ export enum SimplePage { export enum ComplexPage { BitVerse = "BitVerse", - Infiltration = "Infiltration", Faction = "Faction", FactionAugmentations = "Faction Augmentations", ScriptEditor = "Script Editor", diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index 09c1da1200..6575fce600 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -303,7 +303,7 @@ export function GameRoot(): React.ReactElement { break; } case Page.Infiltration: { - mainPage = ; + mainPage = ; withSidebar = false; break; } diff --git a/src/ui/Router.ts b/src/ui/Router.ts index 2cf895f977..dda1ab6671 100644 --- a/src/ui/Router.ts +++ b/src/ui/Router.ts @@ -12,8 +12,6 @@ export const Page = { ...SimplePage, ...ComplexPage }; export type PageContext = T extends ComplexPage.BitVerse ? { flume: boolean; quick: boolean } - : T extends ComplexPage.Infiltration - ? { location: Location } : T extends ComplexPage.Faction ? { faction: Faction } : T extends ComplexPage.FactionAugmentations @@ -30,7 +28,6 @@ export type PageContext = T extends ComplexPage.BitVerse export type PageWithContext = | ({ page: ComplexPage.BitVerse } & PageContext) - | ({ page: ComplexPage.Infiltration } & PageContext) | ({ page: ComplexPage.Faction } & PageContext) | ({ page: ComplexPage.FactionAugmentations } & PageContext) | ({ page: ComplexPage.ScriptEditor } & PageContext) From 46cd00e2a1b91348670c92d68b68d9ea67c842e0 Mon Sep 17 00:00:00 2001 From: David Walker Date: Wed, 29 Oct 2025 22:38:22 -0700 Subject: [PATCH 2/3] Fix console warning in InfiltrationRoot It turns out true isn't actually a safe value to use in JSX, only false works. --- src/Infiltration/ui/GameTimer.tsx | 2 +- src/Infiltration/ui/InfiltrationRoot.tsx | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Infiltration/ui/GameTimer.tsx b/src/Infiltration/ui/GameTimer.tsx index 06b2f74884..894fe6111c 100644 --- a/src/Infiltration/ui/GameTimer.tsx +++ b/src/Infiltration/ui/GameTimer.tsx @@ -21,7 +21,7 @@ export function GameTimer({ endTimestamp }: GameTimerProps): React.ReactElement useEffect(() => { // All manipulation must be done in an effect, since this is after React's // "commit," where the DOM is materialized. - const ele = ref.current?.firstElementChild as HTMLElement; + const ele = ref.current?.firstElementChild; const startTimestamp = changeRef.current.startTimestamp; if (!ele) return; // The delay will be negative. This is because the animation starts diff --git a/src/Infiltration/ui/InfiltrationRoot.tsx b/src/Infiltration/ui/InfiltrationRoot.tsx index 7a44c2116a..e1d6063ced 100644 --- a/src/Infiltration/ui/InfiltrationRoot.tsx +++ b/src/Infiltration/ui/InfiltrationRoot.tsx @@ -110,7 +110,7 @@ export function InfiltrationRoot(): React.ReactElement { ) : ( - {state.stage instanceof VictoryModel || ( + {!(state.stage instanceof VictoryModel) && ( @@ -121,11 +121,14 @@ export function InfiltrationRoot(): React.ReactElement { - {state.stage instanceof CountdownModel || state.stage instanceof VictoryModel || ( - - - - )} + { + // The logic is weird here because "false" gets dropped but "true" generates a console warning + !(state.stage instanceof CountdownModel || state.stage instanceof VictoryModel) && ( + + + + ) + } From d0536a268586cac083a7748a8ab5a8cfe64a0352 Mon Sep 17 00:00:00 2001 From: David Walker Date: Sun, 16 Nov 2025 22:42:06 -0800 Subject: [PATCH 3/3] Fix up some comments --- src/Infiltration/Infiltration.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Infiltration/Infiltration.ts b/src/Infiltration/Infiltration.ts index b392a4e15a..a4bec5996d 100644 --- a/src/Infiltration/Infiltration.ts +++ b/src/Infiltration/Infiltration.ts @@ -46,10 +46,20 @@ export class Infiltration { /** Used to avoid repeating games too quickly. gameIds[0] is the current (or last) game. */ gameIds = [-1, -1, -1]; - /** Invalid until infiltration is started, used to calculate rewards */ + /** + * Invalid until infiltration is started, used to calculate rewards. + * Timestamp based on Date.now(), because it is compared against something + * stored in the savegame. + */ gameStartTimestamp = -1; - /** undefined for timeouts that have finished. Typescript isn't happy with null. */ + + /** End of stage, based on performance.now() since it is never persisted. */ stageEndTimestamp = -1; + + /** + * Used to clean up pending stage timeouts if Infil is cancelled, undefined for + * timeouts that have finished. Typescript isn't happy with passing null to clearTimeout(). + */ timeoutIds: (ReturnType | undefined)[] = []; stage: InfiltrationStage;