Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add start mission button #96

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/lib/demil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import semver from 'semver';
export interface StartMissionOptions {
steamID?: string;
seed?: number;
force?: boolean;
}
export interface StartMissionByNameOptions {
samfundev marked this conversation as resolved.
Show resolved Hide resolved
seed?: number;
force?: boolean;
}

export interface DeMiLResultBase {
Version?: string;
IsVersionInRange?: boolean;
}

export interface StartMissionSuccessResult extends DeMiLResultBase {
MissionID: string;
Seed: string;
}
export interface StartMissionMissingModulesResult extends DeMiLResultBase {
MissionID: string;
MissingModules: string[];
}
export interface StartMissionTooManyModulesResult extends DeMiLResultBase {
MissionID: string;
MaximumSupportedModulesCount: number;
MissionModulesCount: number;
}
export interface StartMissionTooManyModulesFrontfaceResult extends DeMiLResultBase {
MissionID: string;
MaximumSupportedFrontfaceModulesCount: number;
MissionModulesCount: number;
}
export interface StartMissionTooManyBombsResult extends DeMiLResultBase {
MissionID: string;
MaximumSupportedBombsCount: number;
MissionBombsCount: number;
}
export type StartMissionResult =
| StartMissionSuccessResult
| StartMissionMissingModulesResult
| StartMissionTooManyModulesResult
| StartMissionTooManyModulesFrontfaceResult
| StartMissionTooManyBombsResult;

export interface DeMiLErrorResponse extends DeMiLResultBase {
ERROR: string;
Stacktrace?: string;
}

export class DeMiLError extends Error {
public internalStack?: string;
public version?: string;
public isVersionInRange: boolean;

public constructor(obj: DeMiLErrorResponse) {
super(obj.ERROR);
this.internalStack = obj.Stacktrace;
this.version = obj.Version;
this.isVersionInRange = obj.IsVersionInRange ?? false;
}

public static isDeMiLErrorResponse(obj: any): obj is DeMiLErrorResponse {
return obj.hasOwnProperty('ERROR') && typeof obj['ERROR'] === 'string';
}

static modNotFoundRegex = /Mod with steamID (\d+) not found/;
public static ParseIfErrorResponse(obj: any): DeMiLError | undefined {
if (this.isDeMiLErrorResponse(obj)) {
const modNotFoundMatch = obj.ERROR.match(DeMiLError.modNotFoundRegex);

if (modNotFoundMatch !== null) {
return new DeMiLModNotFoundError({ ...obj, steamID: modNotFoundMatch[1] });
}
return new DeMiLError(obj);
}
return;
}
}

export class DeMiLModNotFoundError extends DeMiLError {
public steamID?: string;

public constructor(obj: DeMiLErrorResponse & { steamID: string }) {
super(obj);
this.steamID = obj.steamID;
}
}

const versionRange = '>=2.1.1';

export class DeMiLClient {
constructor(private port: number) {}

/**
* Start the mission with specified mission name.
* @param missionName Mission name for the mission. If mission with the exact name exist, or there are only one mission that partially match the name, the mission will be activated.
* @param steamID SteamID of the mission pack that the mission is in.
* @param options Options to start the mission.
* @param options.seed Specify mission seed.
* @param options.force Start the mission without checking the mission can be run in the current setting. Default: false
* @returns Mission ID and seed
* @see {@link https://github.com/tepel-chen/DeMiLService/wiki/API-Reference#httplocalhostportstartmissionmissionidmissionid}
*/
async startMissionByName(
missionName: string,
steamID: string,
options: StartMissionByNameOptions = {}
): Promise<StartMissionResult> {
const parsedOptions = Object.fromEntries(
Object.entries({
...options,
seed: options.seed?.toString(),
force: options.force ? 'true' : 'false',
missionName,
steamID
}).filter((kvp): kvp is [string, string] => typeof kvp[1] === 'string')
);

return this.get<StartMissionResult>('startMission', parsedOptions);
}

private async get<T extends DeMiLResultBase>(path: string, params: Record<string, string> = {}): Promise<T> {
const urlParams = new URLSearchParams(params);
const url = new URL('http://localhost');
url.port = this.port.toString();
url.pathname = path;
url.search = urlParams.toString();

const data: DeMiLResultBase = await (await fetch(url)).json();

if (typeof data.Version === 'string' && semver.satisfies(data.Version, versionRange)) {
data.IsVersionInRange = true;
} else {
data.IsVersionInRange = false;
}

const demilError = DeMiLError.ParseIfErrorResponse(data);
if (demilError) throw demilError;
return data as T;
}

/**
* Check if StartMissionResult was successful
* @param result
* @returns true if tartMissionResult was successful
*/
static isStartMissionSuccess(result: StartMissionResult): result is StartMissionSuccessResult {
return result.hasOwnProperty('Seed');
}
}
1 change: 1 addition & 0 deletions src/lib/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type RepoModule = {
RuleSeedSupport: string | null;
Type: string;
Quirks: string | null;
SteamID: string | null;
X: number;
Y: number;
};
Expand Down
1 change: 1 addition & 0 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export function getModule(moduleID: string, modules: Record<string, RepoModule>
RuleSeedSupport: null,
Type: moduleID.match(/needy/gi) ? 'Needy' : 'Regular',
Quirks: null,
SteamID: null,
X: 0,
Y: 0
};
Expand Down
48 changes: 47 additions & 1 deletion src/routes/mission/[...mission]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import Select from '$lib/controls/Select.svelte';
import * as DeMiL from '$lib/demil';
import toast from 'svelte-french-toast';
import DeMiLErrorDialog from './_DeMiLErrorDialog.svelte';

type Variant = Pick<Mission, 'name' | 'completions' | 'tpSolve'>;
export let data;
Expand All @@ -32,6 +35,12 @@
const dateOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
// const dateOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' };

let demilHelpState: 'Error' | 'InvalidVersion' | 'NotInstalled' | 'MissingModules' | 'MissionNotFound' | undefined;
let missingModules: RepoModule[];
let demilErrorMessage: string;
let demilVersion: string | undefined;
const steamID = mission.missionPack.steamId;

function poolClass(mods: string[] = [], module: RepoModule | null = null): string {
let classes = '';
if (module || mods.length == 1) {
Expand Down Expand Up @@ -72,6 +81,40 @@
};
}

let demilClient = new DeMiL.DeMiLClient(8095);
async function startMission() {
demilHelpState = undefined;
try {
const missionResult = await demilClient.startMissionByName(mission.name, steamID);
demilVersion = !missionResult.IsVersionInRange ? missionResult.Version ?? '<2.1.0' : undefined;
if (missionResult.hasOwnProperty('MissingModules')) {
missingModules = (missionResult as DeMiL.StartMissionMissingModulesResult).MissingModules.map(mod =>
getModule(mod, modules)
);
demilHelpState = 'MissingModules';
} else if (missionResult.hasOwnProperty('MissionModulesCount')) {
let moduleCountError = missionResult as DeMiL.StartMissionTooManyModulesResult;
demilErrorMessage = `Failed to start mission. A bomb that can support more modules is required. Current bombs only support up to ${moduleCountError.MaximumSupportedModulesCount} modules, and the mission has ${moduleCountError.MissionModulesCount} modules.`;
demilHelpState = 'Error';
} else if (missionResult.hasOwnProperty('MissionBombsCount')) {
let bombCountError = missionResult as DeMiL.StartMissionTooManyBombsResult;
demilErrorMessage = `A room that can support more bombs is required. Current rooms only support up to ${bombCountError.MaximumSupportedBombsCount} bombs, and the mission has ${bombCountError.MissionBombsCount} bombs.`;
demilHelpState = 'Error';
} else {
toast.success(`Started mission ${missionResult.MissionID}`);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't use the mission ID but the mission name.

Comment on lines +84 to +104
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think literally all of this from line 84 to 117 can be in the component instead of in the mission page code and you just pass it the mission object.

}
} catch (e) {
console.error(e);
if (e instanceof TypeError) {
demilHelpState = 'NotInstalled';
} else if (e instanceof DeMiL.DeMiLError && e.message.match(/Mod with steamID \d+ not found/)) {
demilHelpState = 'MissionNotFound';
} else if (e instanceof Error) {
demilHelpState = 'Error';
demilErrorMessage = 'DeMiL threw an error: ' + e.message;
}
}
}
sortBombs(mission, modules);

type BombFrac = {
Expand Down Expand Up @@ -130,6 +173,7 @@
{#if mission.logfile !== null}
<a class="logfile" href={mission.logfile}>Logfile</a>
{/if}
<button class="start-mission" on:click={startMission}>Start Mission</button>
</div>
{#if hasPermission($page.data.user, Permission.VerifyMission)}
<a href={$page.url.href + '/edit'} class="top-right">Edit</a>
Expand All @@ -154,6 +198,7 @@
{/if}
</div>
{/if}
<DeMiLErrorDialog {demilHelpState} {steamID} {missingModules} {demilErrorMessage} {demilVersion} />
<div class="main-content">
<div class="bombs">
<div class="block legend-bar flex">
Expand Down Expand Up @@ -281,7 +326,8 @@
color: var(--text-color);
}
a.logfile,
.date {
.date,
.start-mission {
margin-left: 20px;
}
a.variant {
Expand Down
90 changes: 90 additions & 0 deletions src/routes/mission/[...mission]/_DeMiLErrorDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script lang="ts">
import Dialog from '$lib/controls/Dialog.svelte';
import type { RepoModule } from '$lib/repo';

export let demilHelpState:
| 'Error'
| 'InvalidVersion'
| 'NotInstalled'
| 'MissingModules'
| 'MissionNotFound'
| undefined;
export let steamID: string;
export let missingModules: RepoModule[];
export let demilErrorMessage: string;
export let demilVersion: string | undefined;

let dialog: HTMLDialogElement;

$: {
if (dialog !== undefined) {
if (demilHelpState !== undefined) {
dialog.showModal();
} else {
dialog.close();
}
}
}
</script>

<Dialog bind:dialog>
{#if typeof demilHelpState !== 'undefined'}
<div class="block">
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove this block element? It makes it look the dialog has a weird thick gray border around it.

{#if demilHelpState === 'NotInstalled'}
<div>
You need to install <a
href="https://steamcommunity.com/sharedfiles/filedetails/?id=2930718104"
target="_blank"
rel="noopener noreferrer">DeMiL</a> to start a mission from this webpage and run the game.
</div>
{/if}
{#if demilHelpState === 'MissionNotFound'}
<div>
Mission pack is not installed. Download mission pack from <a
href="https://steamcommunity.com/sharedfiles/filedetails/?id={steamID}"
target="_blank"
rel="noopener noreferrer">Steam page</a
>.
</div>
{/if}
{#if demilHelpState === 'MissingModules'}
<div>Failed to start mission. Missing modules (click to open steam page):</div>
<div class="missing-modules">
{#each missingModules as mod}
<div>
{#if mod.SteamID !== null}
<a
href="https://steamcommunity.com/sharedfiles/filedetails/?id={mod.SteamID}"
target="_blank"
rel="noopener noreferrer">{mod.Name}</a>
{:else}
{mod.Name}
{/if}
</div>
{/each}
</div>
{/if}
{#if demilHelpState === 'Error'}
<div>{demilErrorMessage}</div>
{/if}
{#if typeof demilVersion === 'string'}
<div>
Your <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2930718104">DeMiL</a> looks outdated.
(Version: {demilVersion}) Please try
<a href="https://help.steampowered.com/en/faqs/view/0C48-FCBD-DA71-93EB">verifying integrity of game files</a
>. If it still doesn't work, please contact t-chen#5876 in KTaNE discord server.
</div>
{/if}
</div>
{/if}
</Dialog>

<style>
.missing-modules {
display: flex;
flex-wrap: wrap;
}
.missing-modules > div {
margin-right: 32px;
}
</style>