diff --git a/src/handbook/src/backend/commands.ts b/src/handbook/src/backend/commands.ts index 1f4d24264..a6d9c71e2 100644 --- a/src/handbook/src/backend/commands.ts +++ b/src/handbook/src/backend/commands.ts @@ -28,10 +28,28 @@ function teleport(scene: number): string { return `/teleport ~ ~ ~ ${scene}`; } +/** + * Generates a basic spawn monster command. + * + * @param monster The monster's ID. + * @param amount The amount of the monster to spawn. + * @param level The level of the monster to spawn. + */ +function spawnMonster(monster: number, amount = 1, level = 1): string { + // Validate the numbers. + if (invalid(monster) || invalid(amount)) return "Invalid arguments."; + + return `/spawn ${monster} x${amount} lv${level}`; +} + export const give = { basic: basicGive }; +export const spawn = { + monster: spawnMonster +}; + export const action = { teleport: teleport }; diff --git a/src/handbook/src/backend/server.ts b/src/handbook/src/backend/server.ts index 2f7c4c68f..208525d86 100644 --- a/src/handbook/src/backend/server.ts +++ b/src/handbook/src/backend/server.ts @@ -95,3 +95,30 @@ export async function teleportTo(scene: number): Promise { }) }).then((res) => res.json()); } + +/** + * Spawns an entity. + * + * @param entity The entity's ID. + * @param amount The amount of the entity to spawn. + * @param level The level of the entity to spawn. + */ +export async function spawnEntity( + entity: number, + amount = 1, + level = 1 +): Promise { + // Validate the numbers. + if (isNaN(entity) || isNaN(amount) || isNaN(level) || amount < 1 || level < 1 || level > 200) + return { status: -1, message: "Invalid arguments." }; + + return await fetch(`https://localhost:443/handbook/spawn`, { + method: "POST", + body: JSON.stringify({ + player: targetPlayer.toString(), + entity: entity.toString(), + amount, + level + }) + }).then((res) => res.json()); +} diff --git a/src/handbook/src/css/pages/ScenesPage.scss b/src/handbook/src/css/pages/ScenesPage.scss index 8762741d4..a40273722 100644 --- a/src/handbook/src/css/pages/ScenesPage.scss +++ b/src/handbook/src/css/pages/ScenesPage.scss @@ -41,4 +41,10 @@ color: var(--text-primary-color); background-color: var(--background-color); + + user-select: none; +} + +.ScenesPage_Button:hover { + cursor: pointer; } diff --git a/src/handbook/src/css/widgets/ItemCard.scss b/src/handbook/src/css/widgets/ObjectCard.scss similarity index 87% rename from src/handbook/src/css/widgets/ItemCard.scss rename to src/handbook/src/css/widgets/ObjectCard.scss index 3781bf25e..bde5e99ff 100644 --- a/src/handbook/src/css/widgets/ItemCard.scss +++ b/src/handbook/src/css/widgets/ObjectCard.scss @@ -1,4 +1,4 @@ -.ItemCard { +.ObjectCard { display: flex; flex-direction: column; justify-content: space-between; @@ -16,20 +16,20 @@ background-color: var(--accent-color); } -.ItemCard_Content { +.ObjectCard_Content { display: flex; gap: 10px; flex-direction: column; } -.ItemCard_Header { +.ObjectCard_Header { display: flex; flex-direction: row; justify-content: space-between; } -.ItemCard_Info { +.ObjectCard_Info { display: flex; flex-direction: column; gap: 10px; @@ -51,12 +51,12 @@ } } -.ItemCard_Icon { +.ObjectCard_Icon { width: 64px; height: 64px } -.ItemCard_Description { +.ObjectCard_Description { display: flex; flex-direction: column; @@ -70,7 +70,7 @@ } } -.ItemCard_Actions { +.ObjectCard_Actions { display: flex; flex-direction: column; @@ -78,7 +78,7 @@ padding-top: 10px; } -.ItemCard_Counter { +.ObjectCard_Counter { display: flex; flex-direction: row; justify-content: space-between; @@ -96,7 +96,7 @@ background-color: var(--secondary-color); } -.ItemCard_Operation { +.ObjectCard_Operation { user-select: none; display: flex; @@ -111,11 +111,11 @@ background-color: var(--background-color); } -.ItemCard_Operation:hover { +.ObjectCard_Operation:hover { cursor: pointer; } -.ItemCard_Count { +.ObjectCard_Count { max-width: 105px; height: 48px; @@ -126,11 +126,11 @@ border: transparent; } -.ItemCard_Count:focus { +.ObjectCard_Count:focus { outline: none; } -.ItemCard_Submit { +.ObjectCard_Submit { width: 100%; height: 46px; max-width: 260px; @@ -148,6 +148,6 @@ user-select: none; } -.ItemCard_Submit:hover { +.ObjectCard_Submit:hover { cursor: pointer; } diff --git a/src/handbook/src/ui/widgets/EntityCard.tsx b/src/handbook/src/ui/widgets/EntityCard.tsx index 0e3829a45..0ffea5d53 100644 --- a/src/handbook/src/ui/widgets/EntityCard.tsx +++ b/src/handbook/src/ui/widgets/EntityCard.tsx @@ -1,9 +1,11 @@ import React from "react"; import type { Entity as EntityType, EntityInfo } from "@backend/types"; -import { entityIcon } from "@app/utils"; +import { copyToClipboard, entityIcon } from "@app/utils"; -import "@css/widgets/ItemCard.scss"; +import "@css/widgets/ObjectCard.scss"; +import { connected, spawnEntity } from "@backend/server"; +import { spawn } from "@backend/commands"; /** * Converts a description string into a list of paragraphs. @@ -80,7 +82,14 @@ class EntityCard extends React.Component { * @private */ private async summonAtPlayer(): Promise { - // TODO: Implement server access. + const entity = this.props.entity?.id ?? 21010101; + const amount = typeof this.state.count == "string" ? parseInt(this.state.count) : this.state.count; + + if (connected) { + await spawnEntity(entity, amount, 1); + } else { + await copyToClipboard(spawn.monster(entity, amount, 1)); + } } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { @@ -94,17 +103,17 @@ class EntityCard extends React.Component { const data = info?.data; return entity ? ( -
-
-
-
+
+
+
+

{data?.name ?? entity.name}

{data?.type ?? ""}

{this.state.icon && ( {entity.name} this.setState({ icon: false })} @@ -112,25 +121,25 @@ class EntityCard extends React.Component { )}
-
{toDescription(data?.description)}
+
{toDescription(data?.description)}
-
-
+
+
this.addCount(false, false)} onContextMenu={(e) => { e.preventDefault(); this.addCount(false, true); }} - className={"ItemCard_Operation"} + className={"ObjectCard_Operation"} > -
{ if (this.state.count == "") { @@ -144,13 +153,13 @@ class EntityCard extends React.Component { e.preventDefault(); this.addCount(true, true); }} - className={"ItemCard_Operation"} + className={"ObjectCard_Operation"} > +
-
diff --git a/src/handbook/src/ui/widgets/ItemCard.tsx b/src/handbook/src/ui/widgets/ItemCard.tsx index 40159afd6..c54e7a226 100644 --- a/src/handbook/src/ui/widgets/ItemCard.tsx +++ b/src/handbook/src/ui/widgets/ItemCard.tsx @@ -8,7 +8,7 @@ import { copyToClipboard, itemIcon } from "@app/utils"; import { connected, giveItem } from "@backend/server"; import { give } from "@backend/commands"; -import "@css/widgets/ItemCard.scss"; +import "@css/widgets/ObjectCard.scss"; /** * Converts a description string into a list of paragraphs. @@ -106,17 +106,17 @@ class ItemCard extends React.Component { const data = info?.data; return item ? ( -
-
-
-
+
+
+
+

{data?.name ?? item.name}

{data?.type ?? itemTypeToString(item.type)}

{this.state.icon && ( {item.name} this.setState({ icon: false })} @@ -124,25 +124,25 @@ class ItemCard extends React.Component { )}
-
{toDescription(data?.description)}
+
{toDescription(data?.description)}
-
-
+
+
this.addCount(false, false)} onContextMenu={(e) => { e.preventDefault(); this.addCount(false, true); }} - className={"ItemCard_Operation"} + className={"ObjectCard_Operation"} > -
{ if (this.state.count == "") { @@ -156,13 +156,13 @@ class ItemCard extends React.Component { e.preventDefault(); this.addCount(true, true); }} - className={"ItemCard_Operation"} + className={"ObjectCard_Operation"} > +
-
diff --git a/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java b/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java index a8d405c11..e1dea91e5 100644 --- a/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java +++ b/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java @@ -1,11 +1,10 @@ package emu.grasscutter.server.http.documentation; -import static emu.grasscutter.config.Configuration.HANDBOOK; - import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter.ServerRunMode; import emu.grasscutter.data.GameData; import emu.grasscutter.game.avatar.Avatar; +import emu.grasscutter.game.entity.EntityMonster; import emu.grasscutter.game.inventory.GameItem; import emu.grasscutter.game.props.ActionReason; import emu.grasscutter.server.http.Router; @@ -13,8 +12,11 @@ import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.objects.HandbookBody; import io.javalin.Javalin; import io.javalin.http.Context; + import java.util.Objects; +import static emu.grasscutter.config.Configuration.HANDBOOK; + /** Handles requests for the new GM Handbook. */ public final class HandbookHandler implements Router { private final byte[] handbook; @@ -218,7 +220,66 @@ public final class HandbookHandler implements Router { ctx.json(HandbookBody.Response.builder().status(200).message("Player teleported.").build()); } catch (NumberFormatException ignored) { - ctx.status(400).result("Invalid scene ID."); + ctx.status(400).result("Invalid player UID or scene ID."); + } catch (Exception exception) { + ctx.status(500).result("An error occurred while teleporting to the scene."); + Grasscutter.getLogger().debug("A handbook command error occurred.", exception); + } + } + + /** + * Spawns an entity in the world. + * + * @route POST /handbook/spawn + * @param ctx The Javalin request context. + */ + private void spawnEntity(Context ctx) { + if (!this.controlSupported()) { + ctx.status(500).result("Handbook control not supported."); + return; + } + + // Parse the request body into a class. + var request = ctx.bodyAsClass(HandbookBody.SpawnEntity.class); + // Validate the request. + if (request.getPlayer() == null || request.getEntity() == null) { + ctx.status(400).result("Invalid request."); + return; + } + + try { + // Parse the requested player. + var playerId = Integer.parseInt(request.getPlayer()); + var player = Grasscutter.getGameServer().getPlayerByUid(playerId); + + // Parse the requested entity. + var entityId = Integer.parseInt(request.getEntity()); + var entityData = GameData.getMonsterDataMap().get(entityId); + + // Validate the request. + if (player == null || entityData == null) { + ctx.status(400).result("Invalid player UID or entity ID."); + return; + } + + // Validate request properties. + var scene = player.getScene(); + var level = request.getLevel(); + if (scene == null || level > 200 || level < 1) { + ctx.status(400).result("Invalid scene or level."); + return; + } + + // Create the entity. + for (var i = 1; i <= request.getAmount(); i++) { + var entity = new EntityMonster(scene, entityData, + player.getPosition(), level); + scene.addEntity(entity); + } + + ctx.json(HandbookBody.Response.builder().status(200).message("Entity(s) spawned.").build()); + } catch (NumberFormatException ignored) { + ctx.status(400).result("Invalid player UID or entity ID."); } catch (Exception exception) { ctx.status(500).result("An error occurred while teleporting to the scene."); Grasscutter.getLogger().debug("A handbook command error occurred.", exception); diff --git a/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java b/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java index f1c96e93f..a200ecef9 100644 --- a/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java +++ b/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java @@ -35,4 +35,13 @@ public interface HandbookBody { private String player; // Parse into online player ID. private String scene; // Parse into a scene ID. } + + @Getter + class SpawnEntity { + private String player; // Parse into online player ID. + private String entity; // Parse into entity ID. + + private int amount = 1; // Range between 1 - Long.MAX_VALUE. + private int level = 1; // Range between 1 - 200. + } }