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..894fe6111c 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; + 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..e1d6063ced 100644 --- a/src/Infiltration/ui/InfiltrationRoot.tsx +++ b/src/Infiltration/ui/InfiltrationRoot.tsx @@ -1,57 +1,137 @@ -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} + + + + + { + // The logic is weird here because "false" gets dropped but "true" generates a console warning + !(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)