REFACTOR: Rewrite infiltration to pull state out of React (#2316)

* REFACTOR: Rewrite infiltration to pull state out of React

Upcoming projects (auto-infil, the possibility of making infil a work
task, etc.) require infiltration state to transition with predictable
timing and be under our control, instead of inside React. This refactor
accomplishes this by pulling the state out into accompanying model
classes.

After this, infiltration can theoretically run headless (without UI),
although it doesn't actually, and you would quickly be
hospitalized due to failing all the minigames.

There should be no user-visible changes, aside from the progress-bars
scrolling much more smoothly.

* Fix console warning in InfiltrationRoot

It turns out true isn't actually a safe value to use in JSX, only false works.

* Fix up some comments
This commit is contained in:
David Walker
2025-11-16 22:55:06 -08:00
committed by GitHub
parent 57ca5ffeaf
commit 4d230c3121
39 changed files with 1620 additions and 1496 deletions

View File

@@ -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());
}

View File

@@ -0,0 +1,228 @@
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.
* Timestamp based on Date.now(), because it is compared against something
* stored in the savegame.
*/
gameStartTimestamp = -1;
/** End of stage, based on performance.now() since it is never persisted. */
stageEndTimestamp = -1;
/**
* Used to clean up pending stage timeouts if Infil is cancelled, undefined for
* timeouts that have finished. Typescript isn't happy with passing null to clearTimeout().
*/
timeoutIds: (ReturnType<typeof setTimeout> | 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);
}
}
}

View File

@@ -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;
}

View File

@@ -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",
];

View File

@@ -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);
}
}

View File

@@ -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",
];

View File

@@ -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);
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,28 @@
interface DifficultySettings<T> {
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<T extends Record<string, number>>(a: T, b: T, t: number): T {
const out: Record<string, number> = {};
for (const key of Object.keys(a)) {
out[key] = lerp(a[key], b[key], t);
}
return out as T;
}
export function interpolate<T extends Record<string, number>>(settings: DifficultySettings<T>, 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);
}

View File

@@ -0,0 +1,5 @@
import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage";
export class IntroModel implements InfiltrationStage {
onKey(__: KeyboardLikeEvent) {}
}

View File

@@ -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<boolean>(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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
import type { InfiltrationStage, KeyboardLikeEvent } from "../InfiltrationStage";
export class VictoryModel implements InfiltrationStage {
onKey(__: KeyboardLikeEvent) {}
}

View File

@@ -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<number>();
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);
}
}

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center", pb: 1 }}>
<Typography variant="h4">Type it{!hasAugment ? " backward" : ""}</Typography>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
<Typography style={{ transform: hasAugment ? "none" : "scaleX(-1)" }}>{answer}</Typography>
<Typography style={{ transform: hasAugment ? "none" : "scaleX(-1)" }}>{stage.answer}</Typography>
<Typography>
{guess}
{stage.guess}
<BlinkingCursor />
</Typography>
</Paper>
</>
);
}
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",
];

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center" }}>
<Typography variant="h4">Close the brackets</Typography>
<Typography style={{ fontSize: "5em" }}>
{`${left}${right}`}
{`${stage.left}${stage.right}`}
<BlinkingCursor />
</Typography>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
</Paper>
</>
);

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center" }}>
<Typography variant="h4">Say something nice about the guard</Typography>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
<Typography variant="h5" color={upColor}>
{upArrowSymbol}
</Typography>
@@ -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",
];

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center" }}>
<Typography variant="h4">Enter the Code!</Typography>
<Typography variant="h4">
@@ -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 (
<span key={i} style={i !== index ? { opacity: 0.4 } : {}}>
{i > index && !hasAugment ? "?" : arrow}
<span key={i} style={i !== stage.index ? { opacity: 0.4 } : {}}>
{i > stage.index && !hasAugment ? "?" : arrow}
</span>
);
})}
</div>
</Typography>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
</Paper>
</>
);
}
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;
}

View File

@@ -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 (
<Paper sx={{ p: 1, textAlign: "center" }}>
<Typography variant="h4">Get Ready!</Typography>
<Typography variant="h4">{x}</Typography>
<Typography variant="h4">{stage.count}</Typography>
</Paper>
);
}

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center", pb: 1 }}>
<Typography variant="h4">Match the symbols!</Typography>
<Typography variant="h5" color={Settings.theme.primary}>
Targets:{" "}
{answers.map((a, i) => {
if (i == currentAnswerIndex)
{stage.answers.map((a, i) => {
if (i == stage.currentAnswerIndex)
return (
<span key={`${i}`} style={{ fontSize: "1em", color: Settings.theme.infolight }}>
{a}&nbsp;
@@ -122,7 +60,7 @@ export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement {
<Box
sx={{
display: "grid",
gridTemplateColumns: `repeat(${Math.round(difficulty.width)}, 1fr)`,
gridTemplateColumns: `repeat(${stage.settings.width}, 1fr)`,
gap: 1,
}}
>
@@ -141,32 +79,7 @@ export function Cyberpunk2077Game(props: IMinigameProps): React.ReactElement {
</Typography>
))}
</Box>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
</Paper>
</>
);
}
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;
}

View File

@@ -1,29 +0,0 @@
type DifficultySetting = Record<string, number>;
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);
}

View File

@@ -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 = <Countdown onFinish={() => setStage(Stage.Minigame)} />;
break;
case Stage.Minigame: {
const MiniGame = minigames[gameIds.id];
stageComponent = <MiniGame onSuccess={onSuccess} onFailure={onFailure} difficulty={difficulty + level / 50} />;
break;
}
case Stage.Sell:
stageComponent = (
<Victory
startingSecurityLevel={startingSecurityLevel}
difficulty={difficulty}
reward={reward}
timestamp={timestamp}
maxLevel={maxLevel}
/>
);
break;
}
function Progress(): React.ReactElement {
return (
<Typography variant="h4">
<span style={{ color: "gray" }}>{results.slice(0, results.length - 1)}</span>
{results[results.length - 1]}
</Typography>
);
}
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 (
<Container>
<Paper sx={{ p: 1, mb: 1, display: "grid", justifyItems: "center", gap: 1 }}>
{stage !== Stage.Sell && (
<Button sx={{ width: "100%" }} onClick={cancel}>
Cancel Infiltration
</Button>
)}
<Typography variant="h5">
Level {level} / {maxLevel}
</Typography>
<Progress />
</Paper>
{stageComponent}
</Container>
);
}

View File

@@ -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;
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<Element> = 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(() => {
if (v <= 0) {
onExpire();
}
}, [v, onExpire]);
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 ? (
<ProgressBar variant="determinate" value={Math.max(v, 0)} color="primary" />
) : (
<Paper sx={{ p: 1, mb: 1 }}>
<ProgressBar variant="determinate" value={Math.max(v, 0)} color="primary" />
</Paper>
);
// 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(() => <ProgressBar ref={ref} variant="determinate" value={100} color="primary" />, []);
}

View File

@@ -1,8 +0,0 @@
export interface IMinigameProps {
onSuccess: () => void;
onFailure: (options?: {
/** Failed due to using untrusted events (automation) */
automated: boolean;
}) => void;
difficulty: number;
}

View File

@@ -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);
// 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<StageProps>][]);
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 <></>;
function Progress({ results }: { results: string }): React.ReactElement {
return (
<Typography variant="h4">
<span style={{ color: "gray" }}>{results.slice(-15, -1)}</span>
{results[results.length - 1]}
</Typography>
);
}
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 (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "calc(100vh - 16px)" }}>
<Typography variant="h2">Not currently infiltrating!</Typography>
</div>
);
}
const startingSecurityLevel = props.location.infiltrationData.startingSecurityLevel;
const difficulty = calculateDifficulty(startingSecurityLevel);
function cancel(): void {
Router.toPage(Page.City);
const StageComponent = stages.get(state.stage.constructor as new () => object);
if (!StageComponent) {
throw new Error("Internal error: Unknown stage " + state.stage.constructor.name);
}
return (
<div style={{ display: "flex", alignItems: "center", height: "calc(100vh - 16px)" }}>
{start ? (
<Game
startingSecurityLevel={startingSecurityLevel}
difficulty={difficulty}
maxLevel={props.location.infiltrationData.maxClearanceLevel}
/>
{state.stage instanceof IntroModel ? (
<Intro state={state} stage={state.stage} />
) : (
<Intro
location={props.location}
startingSecurityLevel={startingSecurityLevel}
difficulty={difficulty}
maxLevel={props.location.infiltrationData.maxClearanceLevel}
start={() => setStart(true)}
cancel={cancel}
/>
<Container>
<Paper sx={{ p: 1, mb: 1, display: "grid", justifyItems: "center", gap: 1 }}>
{!(state.stage instanceof VictoryModel) && (
<Button sx={{ width: "100%" }} onClick={cancel}>
Cancel Infiltration
</Button>
)}
<Typography variant="h5">
Level {state.level} / {state.maxLevel}
</Typography>
<Progress results={state.results} />
</Paper>
{
// The logic is weird here because "false" gets dropped but "true" generates a console warning
!(state.stage instanceof CountdownModel || state.stage instanceof VictoryModel) && (
<Paper sx={{ p: 1, mb: 1 }}>
<GameTimer endTimestamp={state.stageEndTimestamp} />
</Paper>
)
}
<StageComponent state={state} stage={state.stage} />
</Container>
)}
</div>
);

View File

@@ -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 = (
<Typography color={Settings.theme.error} textAlign="center">
This location is too secure for your current abilities. You cannot infiltrate it.
</Typography>
);
} else if (difficulty >= 1.5) {
} else if (state.startingDifficulty >= 1.5) {
warningMessage = (
<Typography color={difficulty > 2 ? Settings.theme.error : Settings.theme.warning} textAlign="center">
<Typography
color={state.startingDifficulty > 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.
</Typography>
);
@@ -104,19 +107,21 @@ export function Intro({
<Container sx={{ alignItems: "center" }}>
<Paper sx={{ p: 1, mb: 1, display: "grid", justifyItems: "center" }}>
<Typography variant="h4">
Infiltrating <b>{location.name}</b>
Infiltrating <b>{state.location.name}</b>
</Typography>
<Typography variant="h6">
<b>HP: {`${formatHp(Player.hp.current)} / ${formatHp(Player.hp.max)}`}</b>
</Typography>
<Typography variant="h6">
<b>Lose {formatHp(calculateDamageAfterFailingInfiltration(startingSecurityLevel))} HP for each failure</b>
<b>
Lose {formatHp(calculateDamageAfterFailingInfiltration(state.startingSecurityLevel))} HP for each failure
</b>
</Typography>
<Typography variant="h6">
<b>Maximum clearance level: </b>
{maxLevel}
{state.maxLevel}
</Typography>
<br />
@@ -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",
}}
>
<b>Difficulty:&nbsp;</b>
{formatNumberNoSuffix(difficulty * (100 / MaxDifficultyForInfiltration))} / 100
{formatNumberNoSuffix(state.startingDifficulty * (100 / MaxDifficultyForInfiltration))} / 100
</Typography>
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }}>
[{coloredArrow(state.startingDifficulty)}]
</Typography>
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }}>[{coloredArrow(difficulty)}]</Typography>
<Typography
sx={{ lineHeight: "1em", whiteSpace: "pre" }}
>{`▲ ▲ ▲ ▲ ▲`}</Typography>
@@ -194,7 +205,7 @@ export function Intro({
</ul>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", width: "100%" }}>
<Button onClick={start} disabled={difficulty >= MaxDifficultyForInfiltration}>
<Button onClick={start} disabled={state.startingDifficulty >= MaxDifficultyForInfiltration}>
Start
</Button>
<Button onClick={cancel}>Cancel</Button>

View File

@@ -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 <></>;
}

View File

@@ -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 (
<>
<GameTimer millis={timer} onExpire={props.onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center", pb: 1 }}>
<Typography variant="h4">{memoryPhase ? "Remember all the mines!" : "Mark all the mines!"}</Typography>
<Typography variant="h4">{stage.memoryPhase ? "Remember all the mines!" : "Mark all the mines!"}</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: `repeat(${Math.round(difficulty.width)}, 1fr)`,
gridTemplateRows: `repeat(${Math.round(difficulty.height)}, 1fr)`,
gridTemplateColumns: `repeat(${stage.settings.width}, 1fr)`,
gridTemplateRows: `repeat(${stage.settings.height}, 1fr)`,
gap: 1,
}}
>
{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 (
<Typography
key={uniqueId()}
key={i}
sx={{
color: color,
border: `2px solid ${item.current ? Settings.theme.infolight : Settings.theme.primary}`,
@@ -151,37 +82,7 @@ export function MinesweeperGame(props: IMinigameProps): React.ReactElement {
);
})}
</Box>
<KeyHandler onKeyDown={press} onFailure={props.onFailure} />
</Paper>
</>
);
}
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<boolean>(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;
}

View File

@@ -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<number | ReturnType<typeof setTimeout>>(-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 (
<>
<GameTimer millis={5000} onExpire={onFailure} ignoreAugment_WKSharmonizer />
<Paper sx={{ display: "grid", justifyItems: "center" }}>
<Typography variant="h5" textAlign="center">
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!
</Typography>
<br />
{phase === 0 && data.hasAugment && (
{stage.phase === 0 && stage.hasMightOfAres && (
<Box sx={{ my: 1 }}>
<Typography variant="h5">The sentinel will drop his guard and be distracted in ...</Typography>
<GameTimer
millis={data.guardingTime}
onExpire={() => {
startPhase1(data.alertedTime, data.distractedTime);
}}
ignoreAugment_WKSharmonizer
noPaper
tick={20}
/>
<GameTimer endTimestamp={stage.guardingEndTime} />
<br />
</Box>
)}
{phase === 0 && <Typography variant="h4">Guarding ...</Typography>}
{phase === 1 && <Typography variant="h4">Distracted!</Typography>}
{phase === 2 && <Typography variant="h4">Alerted!</Typography>}
<KeyHandler onKeyDown={press} onFailure={onFailure} />
{stage.phase === 0 && <Typography variant="h4">Guarding ...</Typography>}
{stage.phase === 1 && <Typography variant="h4">Distracted!</Typography>}
{stage.phase === 2 && <Typography variant="h4">Alerted!</Typography>}
</Paper>
</>
);

View File

@@ -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);

View File

@@ -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<string, string> = {
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<Question[]>([]);
const [wires, setWires] = useState<Wire[]>([]);
const [timer, setTimer] = useState(0);
const [cutWires, setCutWires] = useState<boolean[]>([]);
const [wiresToCut, setWiresToCut] = useState(new Set<number>());
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<number>();
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 (
<>
<GameTimer millis={timer} onExpire={onFailure} />
<Paper sx={{ display: "grid", justifyItems: "center", pb: 1 }}>
<Typography variant="h4" sx={{ width: "75%", textAlign: "center" }}>
Cut the wires with the following properties! (keyboard 1 to 9)
</Typography>
{questions.map((question, i) => (
{stage.questions.map((question, i) => (
<Typography key={i}>{question.toString()}</Typography>
))}
<Box
sx={{
display: "grid",
gridTemplateColumns: `repeat(${wires.length}, 1fr)`,
gridTemplateColumns: `repeat(${stage.wires.length}, 1fr)`,
columnGap: 3,
justifyItems: "center",
}}
>
{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 (
<Typography key={i} style={{ color: color }}>
@@ -139,13 +39,13 @@ export function WireCuttingGame({ onSuccess, onFailure, difficulty }: IMinigameP
</Typography>
);
})}
{new Array(11).fill(0).map((_, i) => (
{Array.from({ length: 11 }, (_, i) => (
<React.Fragment key={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 <Typography key={j}></Typography>;
}
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
</React.Fragment>
))}
</Box>
<KeyHandler onKeyDown={press} onFailure={onFailure} />
</Paper>
</>
);
}
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;
}

View File

@@ -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:

View File

@@ -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<HTMLElement>): void {

View File

@@ -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. */

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -303,7 +303,7 @@ export function GameRoot(): React.ReactElement {
break;
}
case Page.Infiltration: {
mainPage = <InfiltrationRoot location={pageWithContext.location} />;
mainPage = <InfiltrationRoot />;
withSidebar = false;
break;
}

View File

@@ -12,8 +12,6 @@ export const Page = { ...SimplePage, ...ComplexPage };
export type PageContext<T extends Page> = 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 Page> = T extends ComplexPage.BitVerse
export type PageWithContext =
| ({ page: ComplexPage.BitVerse } & PageContext<ComplexPage.BitVerse>)
| ({ page: ComplexPage.Infiltration } & PageContext<ComplexPage.Infiltration>)
| ({ page: ComplexPage.Faction } & PageContext<ComplexPage.Faction>)
| ({ page: ComplexPage.FactionAugmentations } & PageContext<ComplexPage.FactionAugmentations>)
| ({ page: ComplexPage.ScriptEditor } & PageContext<ComplexPage.ScriptEditor>)