rowork 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/rowork.js +18 -0
- package/dashboard/app.css +234 -0
- package/dashboard/app.js +117 -0
- package/dashboard/index.html +67 -0
- package/dist/cli/errors.d.ts +15 -0
- package/dist/cli/errors.js +18 -0
- package/dist/cli/program.d.ts +19 -0
- package/dist/cli/program.js +59 -0
- package/dist/cli/registry.d.ts +37 -0
- package/dist/cli/registry.js +61 -0
- package/dist/commands/add.d.ts +8 -0
- package/dist/commands/add.js +77 -0
- package/dist/commands/assets-setup.d.ts +18 -0
- package/dist/commands/assets-setup.js +136 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +153 -0
- package/dist/commands/console.d.ts +4 -0
- package/dist/commands/console.js +141 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +50 -0
- package/dist/commands/dev-background.d.ts +3 -0
- package/dist/commands/dev-background.js +87 -0
- package/dist/commands/dev.d.ts +2 -0
- package/dist/commands/dev.js +270 -0
- package/dist/commands/eject.d.ts +2 -0
- package/dist/commands/eject.js +136 -0
- package/dist/commands/index.d.ts +8 -0
- package/dist/commands/index.js +54 -0
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +50 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +55 -0
- package/dist/commands/links.d.ts +32 -0
- package/dist/commands/links.js +152 -0
- package/dist/commands/make-event.d.ts +2 -0
- package/dist/commands/make-event.js +160 -0
- package/dist/commands/make-menu.d.ts +3 -0
- package/dist/commands/make-menu.js +36 -0
- package/dist/commands/make-stat.d.ts +2 -0
- package/dist/commands/make-stat.js +199 -0
- package/dist/commands/make-tool.d.ts +2 -0
- package/dist/commands/make-tool.js +124 -0
- package/dist/commands/make-ui.d.ts +3 -0
- package/dist/commands/make-ui.js +199 -0
- package/dist/commands/make.d.ts +21 -0
- package/dist/commands/make.js +181 -0
- package/dist/commands/next-steps.d.ts +5 -0
- package/dist/commands/next-steps.js +18 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.js +144 -0
- package/dist/commands/studio.d.ts +3 -0
- package/dist/commands/studio.js +45 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +173 -0
- package/dist/commands/wire.d.ts +2 -0
- package/dist/commands/wire.js +34 -0
- package/dist/core/agent-docs.d.ts +23 -0
- package/dist/core/agent-docs.js +151 -0
- package/dist/core/assets.d.ts +96 -0
- package/dist/core/assets.js +268 -0
- package/dist/core/background.d.ts +47 -0
- package/dist/core/background.js +149 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +62 -0
- package/dist/core/exec.d.ts +22 -0
- package/dist/core/exec.js +38 -0
- package/dist/core/format-generated.d.ts +12 -0
- package/dist/core/format-generated.js +28 -0
- package/dist/core/generate.d.ts +37 -0
- package/dist/core/generate.js +94 -0
- package/dist/core/github-release.d.ts +28 -0
- package/dist/core/github-release.js +38 -0
- package/dist/core/info.d.ts +77 -0
- package/dist/core/info.js +65 -0
- package/dist/core/modules.d.ts +32 -0
- package/dist/core/modules.js +265 -0
- package/dist/core/naming.d.ts +6 -0
- package/dist/core/naming.js +26 -0
- package/dist/core/open-cloud.d.ts +58 -0
- package/dist/core/open-cloud.js +170 -0
- package/dist/core/project-index.d.ts +26 -0
- package/dist/core/project-index.js +59 -0
- package/dist/core/rojo-edit.d.ts +18 -0
- package/dist/core/rojo-edit.js +53 -0
- package/dist/core/rokit-installer.d.ts +15 -0
- package/dist/core/rokit-installer.js +77 -0
- package/dist/core/scaffold.d.ts +44 -0
- package/dist/core/scaffold.js +163 -0
- package/dist/core/schema-edit.d.ts +46 -0
- package/dist/core/schema-edit.js +125 -0
- package/dist/core/source-scan.d.ts +24 -0
- package/dist/core/source-scan.js +84 -0
- package/dist/core/studio.d.ts +83 -0
- package/dist/core/studio.js +225 -0
- package/dist/core/toolchain.d.ts +31 -0
- package/dist/core/toolchain.js +98 -0
- package/dist/core/tsconfig-edit.d.ts +11 -0
- package/dist/core/tsconfig-edit.js +47 -0
- package/dist/core/ui-edit.d.ts +31 -0
- package/dist/core/ui-edit.js +89 -0
- package/dist/core/versions.d.ts +24 -0
- package/dist/core/versions.js +67 -0
- package/dist/dashboard/open.d.ts +3 -0
- package/dist/dashboard/open.js +18 -0
- package/dist/dashboard/server.d.ts +28 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/main.d.ts +9 -0
- package/dist/main.js +135 -0
- package/dist/modules/format.d.ts +3 -0
- package/dist/modules/format.js +28 -0
- package/dist/modules/index.d.ts +7 -0
- package/dist/modules/index.js +12 -0
- package/dist/modules/integrations.d.ts +8 -0
- package/dist/modules/integrations.js +54 -0
- package/dist/modules/leaderstats.d.ts +3 -0
- package/dist/modules/leaderstats.js +104 -0
- package/dist/modules/lint.d.ts +5 -0
- package/dist/modules/lint.js +43 -0
- package/dist/modules/networking.d.ts +23 -0
- package/dist/modules/networking.js +246 -0
- package/dist/modules/player-data.d.ts +19 -0
- package/dist/modules/player-data.js +176 -0
- package/dist/modules/types.d.ts +96 -0
- package/dist/modules/types.js +2 -0
- package/dist/modules/ui.d.ts +3 -0
- package/dist/modules/ui.js +67 -0
- package/dist/plugins/api.d.ts +127 -0
- package/dist/plugins/api.js +21 -0
- package/dist/plugins/loader.d.ts +20 -0
- package/dist/plugins/loader.js +128 -0
- package/dist/process/log-mux.d.ts +13 -0
- package/dist/process/log-mux.js +31 -0
- package/dist/process/supervisor.d.ts +48 -0
- package/dist/process/supervisor.js +141 -0
- package/dist/process/tree-kill.d.ts +25 -0
- package/dist/process/tree-kill.js +70 -0
- package/dist/templates/engine.d.ts +18 -0
- package/dist/templates/engine.js +54 -0
- package/dist/ui/logger.d.ts +5 -0
- package/dist/ui/logger.js +53 -0
- package/dist/ui/prompt.d.ts +9 -0
- package/dist/ui/prompt.js +24 -0
- package/package.json +69 -0
- package/templates/init/README.md.tmpl +37 -0
- package/templates/init/_gitignore.tmpl +23 -0
- package/templates/init/default.project.json.tmpl +57 -0
- package/templates/init/package.json.tmpl +12 -0
- package/templates/init/rokit.toml.tmpl +5 -0
- package/templates/init/src/client/controllers/ExampleController.ts.tmpl +12 -0
- package/templates/init/src/client/runtime.client.ts.tmpl +6 -0
- package/templates/init/src/server/runtime.server.ts.tmpl +12 -0
- package/templates/init/src/server/services/ExampleService.ts.tmpl +12 -0
- package/templates/init/src/shared/_gitkeep.tmpl +0 -0
- package/templates/init/tsconfig.json.tmpl +28 -0
- package/templates/integrations/data-replication/DataReplicationService.ts.tmpl +33 -0
- package/templates/integrations/data-replication/PlayerDataController.ts.tmpl +40 -0
- package/templates/integrations/data-replication/dataEvents.ts.tmpl +21 -0
- package/templates/make/component.ts.tmpl +8 -0
- package/templates/make/controller.ts.tmpl +6 -0
- package/templates/make/event-handler.ts.tmpl +22 -0
- package/templates/make/screen.tsx.tmpl +35 -0
- package/templates/make/service.ts.tmpl +6 -0
- package/templates/make/stat-service.ts.tmpl +26 -0
- package/templates/make/ui-button.tsx.tmpl +22 -0
- package/templates/make/ui-image.tsx.tmpl +11 -0
- package/templates/make/ui-label.tsx.tmpl +18 -0
- package/templates/make/ui-panel.tsx.tmpl +33 -0
- package/templates/make/ui-story.tsx.tmpl +13 -0
- package/templates/modules/format/prettierignore.tmpl +9 -0
- package/templates/modules/format/prettierrc.json.tmpl +4 -0
- package/templates/modules/leaderstats/LeaderstatsService.ts.tmpl +69 -0
- package/templates/modules/lint/eslint.config.mjs.tmpl +17 -0
- package/templates/modules/networking/client-network.ts.tmpl +9 -0
- package/templates/modules/networking/networking.ts.tmpl +22 -0
- package/templates/modules/networking/rate-limit.ts.tmpl +52 -0
- package/templates/modules/networking/server-network.ts.tmpl +20 -0
- package/templates/modules/player-data/PlayerData.ts.tmpl +15 -0
- package/templates/modules/player-data/PlayerDataService.ts.tmpl +127 -0
- package/templates/modules/ui/App.tsx.tmpl +19 -0
- package/templates/modules/ui/Button.story.tsx.tmpl +13 -0
- package/templates/modules/ui/Button.tsx.tmpl +21 -0
- package/templates/modules/ui/UiController.tsx.tmpl +21 -0
- package/templates/modules/ui/screens/HomeScreen.tsx.tmpl +20 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { RoworkError } from "../cli/errors.js";
|
|
4
|
+
import { resolveProjectPath } from "../core/config.js";
|
|
5
|
+
import { generateFile } from "../core/generate.js";
|
|
6
|
+
import { addFieldToPlayerData, addToShownList } from "../core/schema-edit.js";
|
|
7
|
+
import { installModule } from "../core/modules.js";
|
|
8
|
+
import { playerDataModule } from "../modules/player-data.js";
|
|
9
|
+
import { defineCommand } from "../plugins/api.js";
|
|
10
|
+
import { defaultFor, toFieldName } from "../modules/player-data.js";
|
|
11
|
+
import { listEvents } from "../core/project-index.js";
|
|
12
|
+
import { answered, prompts, requireInteractive } from "../ui/prompt.js";
|
|
13
|
+
import { askEventLink, createEventHandler, resolveEvent } from "./links.js";
|
|
14
|
+
import { requireProject } from "./make.js";
|
|
15
|
+
const ADD_METHOD = `
|
|
16
|
+
/** Adds to the player's \`{{ name }}\` (1 by default). Use a negative amount to subtract. */
|
|
17
|
+
add(player: Player, amount = 1): void {
|
|
18
|
+
this.playerData.update(player, (data) => {
|
|
19
|
+
data.{{ name }} += amount;
|
|
20
|
+
return data;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
const IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
25
|
+
export const makeStatCommand = defineCommand({
|
|
26
|
+
name: "make:stat",
|
|
27
|
+
description: "Add a saved value (kills, coins...) to the player data, in every place it must be, and a service to use it.",
|
|
28
|
+
guided: true,
|
|
29
|
+
arguments: [{ name: "name", description: "the value, e.g. kills (omit it for the guided version)", required: false }],
|
|
30
|
+
options: [
|
|
31
|
+
{ flags: "--type <type>", description: "number (default), string or boolean" },
|
|
32
|
+
{ flags: "--default <value>", description: "starting value for a new player (default 0, empty, or false)" },
|
|
33
|
+
{ flags: "--leaderboard", description: "show it in the leaderboard (default for numbers)" },
|
|
34
|
+
{ flags: "--no-leaderboard", description: "do not show it in the leaderboard" },
|
|
35
|
+
{ flags: "--service", description: "create the helper service (default for numbers)" },
|
|
36
|
+
{ flags: "--no-service", description: "do not create the helper service" },
|
|
37
|
+
{ flags: "--link <event>", description: "an existing event from the client that changes it, handled on the server" },
|
|
38
|
+
],
|
|
39
|
+
async run(context) {
|
|
40
|
+
const { root, config } = requireProject(context, "make:stat");
|
|
41
|
+
const installed = config.modules ?? [];
|
|
42
|
+
const missingModule = !installed.includes("player-data");
|
|
43
|
+
const given0 = context.args["name"];
|
|
44
|
+
if (missingModule && typeof given0 === "string") {
|
|
45
|
+
// Scripted: no question to ask. Say the one command that does both.
|
|
46
|
+
throw new RoworkError("There is no player data to add a value to.", {
|
|
47
|
+
hint: `Run \`rowork add:player-data --field ${given0}:number=0\`: it installs the saving and starts with that value.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const hasLeaderboard = installed.includes("leaderstats");
|
|
51
|
+
const given = context.args["name"];
|
|
52
|
+
let rawName;
|
|
53
|
+
let type = "number";
|
|
54
|
+
let rawDefault;
|
|
55
|
+
// A counter (kills, coins) is what a leaderboard and add/get/set are for: those
|
|
56
|
+
// are the defaults for numbers. Text and yes/no values get them only on request.
|
|
57
|
+
const wants = (option, fallback) => context.options[option] === undefined ? fallback : context.options[option] === true;
|
|
58
|
+
let leaderboard = false;
|
|
59
|
+
let service = false;
|
|
60
|
+
let linkTyped = typeof context.options["link"] === "string" ? context.options["link"] : undefined;
|
|
61
|
+
if (typeof given === "string") {
|
|
62
|
+
rawName = given;
|
|
63
|
+
const asked = context.options["type"];
|
|
64
|
+
if (asked !== undefined) {
|
|
65
|
+
if (asked !== "number" && asked !== "string" && asked !== "boolean") {
|
|
66
|
+
throw new RoworkError(`Unknown type \`${String(asked)}\`.`, { hint: "Use number, string or boolean." });
|
|
67
|
+
}
|
|
68
|
+
type = asked;
|
|
69
|
+
}
|
|
70
|
+
rawDefault = typeof context.options["default"] === "string" ? context.options["default"] : undefined;
|
|
71
|
+
leaderboard = hasLeaderboard && wants("leaderboard", type === "number");
|
|
72
|
+
service = wants("service", type === "number");
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
requireInteractive("make:stat", "rowork make:stat <name> --type number --default 0");
|
|
76
|
+
rawName = answered(await prompts.text({
|
|
77
|
+
message: "What do you want to save for each player? (anything you like, e.g. kills or best score)",
|
|
78
|
+
placeholder: "kills",
|
|
79
|
+
validate: (value) => (IDENTIFIER.test(toFieldName(value ?? "")) ? undefined : "Use letters and digits, starting with a letter."),
|
|
80
|
+
}));
|
|
81
|
+
type = answered(await prompts.select({
|
|
82
|
+
message: "What kind of value is it?",
|
|
83
|
+
options: [
|
|
84
|
+
{ value: "number", label: "Number", hint: "kills, coins, level" },
|
|
85
|
+
{ value: "string", label: "Text", hint: "a nickname, a title" },
|
|
86
|
+
{ value: "boolean", label: "Yes / no", hint: "has finished the tutorial" },
|
|
87
|
+
],
|
|
88
|
+
}));
|
|
89
|
+
rawDefault = answered(await prompts.text({
|
|
90
|
+
message: "Starting value for a new player?",
|
|
91
|
+
placeholder: type === "number" ? "0" : type === "boolean" ? "false" : "",
|
|
92
|
+
defaultValue: "",
|
|
93
|
+
}));
|
|
94
|
+
if (hasLeaderboard) {
|
|
95
|
+
leaderboard = answered(await prompts.confirm({ message: "Show it in the leaderboard?", initialValue: type === "number" }));
|
|
96
|
+
}
|
|
97
|
+
service = answered(await prompts.confirm({ message: "Create a small service to read and change it (get, set, add)?", initialValue: type === "number" }));
|
|
98
|
+
// "Link it to...?": an existing event from the client that should change this value.
|
|
99
|
+
linkTyped = await askEventLink(root, config, "Should an existing event from the client change it? Type the event name.");
|
|
100
|
+
}
|
|
101
|
+
const name = toFieldName(rawName);
|
|
102
|
+
if (!IDENTIFIER.test(name)) {
|
|
103
|
+
throw new RoworkError(`\`${rawName}\` cannot be used as a name.`, {
|
|
104
|
+
hint: "Use letters and digits, starting with a letter: kills, bestScore.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const defaultValue = defaultFor(type, rawDefault);
|
|
108
|
+
const className = `${name.charAt(0).toUpperCase()}${name.slice(1)}Service`;
|
|
109
|
+
// A link is checked before anything is written.
|
|
110
|
+
const linkEvent = linkTyped === undefined ? undefined : resolveEvent(listEvents(root, config), linkTyped);
|
|
111
|
+
const linkNow = () => {
|
|
112
|
+
if (linkEvent === undefined)
|
|
113
|
+
return;
|
|
114
|
+
const handler = createEventHandler({ root, config, event: { name: linkEvent.name }, stat: { name, type } });
|
|
115
|
+
context.logger.step(`${handler.path}: runs on the server when \`${linkEvent.name}\` arrives, linked to \`${name}\``);
|
|
116
|
+
context.logger.info("It decides the amount on the server: do not use a number the client sent.");
|
|
117
|
+
};
|
|
118
|
+
if (linkEvent !== undefined && existsAt(root, join(config.paths.services, `${linkEvent.name.charAt(0).toUpperCase()}${linkEvent.name.slice(1)}Handler.ts`))) {
|
|
119
|
+
throw new RoworkError(`A handler for \`${linkEvent.name}\` already exists.`, {
|
|
120
|
+
hint: "An event is handled in one place. Open that file to change what it does.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const createService = () => {
|
|
124
|
+
const written = generateFile({
|
|
125
|
+
projectRoot: root,
|
|
126
|
+
directory: config.paths.services,
|
|
127
|
+
fileName: `${className}.ts`,
|
|
128
|
+
template: "stat-service",
|
|
129
|
+
variables: { name, type, className, extra: type === "number" ? ADD_METHOD.replaceAll("{{ name }}", name) : "" },
|
|
130
|
+
force: false,
|
|
131
|
+
});
|
|
132
|
+
if (written !== undefined)
|
|
133
|
+
context.logger.step(written);
|
|
134
|
+
};
|
|
135
|
+
// Guided and nothing saves values yet: offer to install what does, starting with this value.
|
|
136
|
+
if (missingModule) {
|
|
137
|
+
const proceed = answered(await prompts.confirm({
|
|
138
|
+
message: `Saving values needs the Player data module, which is not installed. Add it now, starting with \`${name}\`?`,
|
|
139
|
+
initialValue: true,
|
|
140
|
+
}));
|
|
141
|
+
if (!proceed) {
|
|
142
|
+
prompts.cancel("Nothing changed.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await installModule({ ...context, options: { field: [`${name}:${type}=${rawDefault ?? ""}`] } }, playerDataModule, root, false);
|
|
146
|
+
if (service)
|
|
147
|
+
createService();
|
|
148
|
+
linkNow();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// ---- every edit is computed in memory first: nothing is written unless all of them work
|
|
152
|
+
const dataFile = `${config.paths.shared}/data/PlayerData.ts`;
|
|
153
|
+
const dataPath = resolveProjectPath(root, dataFile);
|
|
154
|
+
let dataSource;
|
|
155
|
+
try {
|
|
156
|
+
dataSource = readFileSync(dataPath, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new RoworkError(`Cannot read ${dataFile}.`, { hint: "It is created by `rowork add:player-data`." });
|
|
160
|
+
}
|
|
161
|
+
const newData = addFieldToPlayerData(dataSource, dataFile, { name, type, defaultValue });
|
|
162
|
+
let newShown;
|
|
163
|
+
if (leaderboard) {
|
|
164
|
+
const shownFile = `${config.paths.services}/LeaderstatsService.ts`;
|
|
165
|
+
const shownPath = resolveProjectPath(root, shownFile);
|
|
166
|
+
newShown = { path: shownPath, source: addToShownList(readFileSync(shownPath, "utf8"), shownFile, name) };
|
|
167
|
+
}
|
|
168
|
+
const servicePath = join(config.paths.services, `${className}.ts`);
|
|
169
|
+
if (service && existsAt(root, servicePath)) {
|
|
170
|
+
throw new RoworkError(`${servicePath} already exists.`, { hint: "Pick another name, or use --no-service." });
|
|
171
|
+
}
|
|
172
|
+
// ---- write
|
|
173
|
+
writeFileSync(dataPath, newData, "utf8");
|
|
174
|
+
context.logger.step(`${dataFile}: added \`${name}: ${type}\` and its starting value`);
|
|
175
|
+
if (newShown !== undefined) {
|
|
176
|
+
writeFileSync(newShown.path, newShown.source, "utf8");
|
|
177
|
+
context.logger.step(`${config.paths.services}/LeaderstatsService.ts: added to the leaderboard`);
|
|
178
|
+
}
|
|
179
|
+
if (service)
|
|
180
|
+
createService();
|
|
181
|
+
linkNow();
|
|
182
|
+
context.logger.success(`Saved \`${name}\` for every player.`);
|
|
183
|
+
context.logger.blank();
|
|
184
|
+
context.logger.info("Players who already have a save get the starting value the next time they join.");
|
|
185
|
+
if (service) {
|
|
186
|
+
context.logger.info(`Use it from any service: constructor(private readonly ${name}: ${className}) {} then this.${name}.${type === "number" ? "add(player)" : "set(player, value)"}`);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
function existsAt(root, relativePath) {
|
|
191
|
+
try {
|
|
192
|
+
readFileSync(resolveProjectPath(root, relativePath));
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=make-stat.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { RoworkError } from "../cli/errors.js";
|
|
3
|
+
import { resolveProjectPath } from "../core/config.js";
|
|
4
|
+
import { ensureFlameworkPath, generateFile, importPath, toClassBase, writeToolRegistry, } from "../core/generate.js";
|
|
5
|
+
import { defineCommand } from "../plugins/api.js";
|
|
6
|
+
import { answered, prompts } from "../ui/prompt.js";
|
|
7
|
+
import { nameOrAsk, requireProject } from "./make.js";
|
|
8
|
+
function parseCooldown(value) {
|
|
9
|
+
const number = Number(value.replace(",", "."));
|
|
10
|
+
return value.trim() !== "" && Number.isFinite(number) && number >= 0 && number <= 3600
|
|
11
|
+
? number
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
async function askSettings() {
|
|
15
|
+
const cooldown = answered(await prompts.text({
|
|
16
|
+
message: "Seconds to wait between two uses?",
|
|
17
|
+
placeholder: "0.5",
|
|
18
|
+
defaultValue: "0.5",
|
|
19
|
+
validate: (value) => parseCooldown(value || "0.5") === undefined ? "A number between 0 and 3600." : undefined,
|
|
20
|
+
}));
|
|
21
|
+
const canBeDropped = answered(await prompts.confirm({ message: "Can the player drop it on the ground?", initialValue: false }));
|
|
22
|
+
const giveOnSpawn = answered(await prompts.confirm({
|
|
23
|
+
message: "Give it to every player when they spawn?",
|
|
24
|
+
initialValue: true,
|
|
25
|
+
}));
|
|
26
|
+
return { cooldown: parseCooldown(cooldown || "0.5") ?? 0.5, canBeDropped, giveOnSpawn };
|
|
27
|
+
}
|
|
28
|
+
function settingsFromOptions(context) {
|
|
29
|
+
const raw = context.options["cooldown"];
|
|
30
|
+
const cooldown = typeof raw === "string" ? parseCooldown(raw) : 0.5;
|
|
31
|
+
if (cooldown === undefined) {
|
|
32
|
+
throw new RoworkError(`\`${String(raw)}\` is not a valid cooldown.`, {
|
|
33
|
+
hint: "Use a number of seconds between 0 and 3600, e.g. --cooldown 0.5",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
cooldown,
|
|
38
|
+
canBeDropped: context.options["droppable"] === true,
|
|
39
|
+
giveOnSpawn: context.options["giveOnSpawn"] !== false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const makeToolCommand = defineCommand({
|
|
43
|
+
name: "make:tool",
|
|
44
|
+
guided: true,
|
|
45
|
+
description: "Create a tool players hold: settings, behaviour, and automatic delivery.",
|
|
46
|
+
arguments: [
|
|
47
|
+
{
|
|
48
|
+
name: "name",
|
|
49
|
+
description: "name of the tool, e.g. Pickaxe (omit it for the guided version)",
|
|
50
|
+
required: false,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
options: [
|
|
54
|
+
{ flags: "--cooldown <seconds>", description: "seconds between two uses (default 0.5)" },
|
|
55
|
+
{ flags: "--droppable", description: "the player can drop it" },
|
|
56
|
+
{ flags: "--no-give-on-spawn", description: "do not give it to players automatically" },
|
|
57
|
+
{ flags: "-f, --force", description: "overwrite the tool's own files if they exist" },
|
|
58
|
+
],
|
|
59
|
+
async run(context) {
|
|
60
|
+
const { root, config } = requireProject(context, "make:tool");
|
|
61
|
+
const { name, guided } = await nameOrAsk(context, "make:tool", "What is the tool called?", "Pickaxe");
|
|
62
|
+
const settings = guided ? await askSettings() : settingsFromOptions(context);
|
|
63
|
+
// `Pickaxe` and `PickaxeTool` both mean the same tool.
|
|
64
|
+
const base = toClassBase(name).replace(/(?<=.)Tool$/, "");
|
|
65
|
+
const constName = `${base}Tool`;
|
|
66
|
+
const force = context.options["force"] === true;
|
|
67
|
+
const sharedDirectory = `${config.paths.shared}/tools`;
|
|
68
|
+
const componentsDirectory = `${config.paths.source}/server/components`;
|
|
69
|
+
const servicesDirectory = config.paths.services;
|
|
70
|
+
const created = [];
|
|
71
|
+
const write = (directory, fileName, template, variables, own) => {
|
|
72
|
+
const written = generateFile({
|
|
73
|
+
projectRoot: root,
|
|
74
|
+
directory,
|
|
75
|
+
fileName,
|
|
76
|
+
template,
|
|
77
|
+
variables,
|
|
78
|
+
force: own && force,
|
|
79
|
+
ifExists: own ? "fail" : "skip",
|
|
80
|
+
});
|
|
81
|
+
if (written !== undefined)
|
|
82
|
+
created.push(written);
|
|
83
|
+
};
|
|
84
|
+
// Shared files first, created once and never overwritten: a failure on
|
|
85
|
+
// the tool's own files then leaves nothing harmful behind.
|
|
86
|
+
write(sharedDirectory, "ToolDefinition.ts", "tool-definition", {}, false);
|
|
87
|
+
write(servicesDirectory, "ToolService.ts", "tool-service", {
|
|
88
|
+
definitionImport: importPath(root, servicesDirectory, `${sharedDirectory}/ToolDefinition`),
|
|
89
|
+
registryImport: importPath(root, servicesDirectory, `${sharedDirectory}/index`),
|
|
90
|
+
}, false);
|
|
91
|
+
write(sharedDirectory, `${constName}.ts`, "tool-config", {
|
|
92
|
+
constName,
|
|
93
|
+
base,
|
|
94
|
+
tag: constName,
|
|
95
|
+
cooldown: String(settings.cooldown),
|
|
96
|
+
canBeDropped: String(settings.canBeDropped),
|
|
97
|
+
giveOnSpawn: String(settings.giveOnSpawn),
|
|
98
|
+
}, true);
|
|
99
|
+
write(componentsDirectory, `${constName}Component.ts`, "tool-component", {
|
|
100
|
+
constName,
|
|
101
|
+
base,
|
|
102
|
+
tag: constName,
|
|
103
|
+
className: `${constName}Component`,
|
|
104
|
+
configImport: importPath(root, componentsDirectory, `${sharedDirectory}/${constName}`),
|
|
105
|
+
}, true);
|
|
106
|
+
writeToolRegistry(root, sharedDirectory);
|
|
107
|
+
const runtime = resolveProjectPath(root, join(config.paths.source, "server", "runtime.server.ts"));
|
|
108
|
+
for (const directory of [servicesDirectory, componentsDirectory]) {
|
|
109
|
+
if (ensureFlameworkPath(runtime, directory, context.logger)) {
|
|
110
|
+
context.logger.step(`registered ${directory} in runtime.server.ts`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const file of created)
|
|
114
|
+
context.logger.step(file);
|
|
115
|
+
context.logger.success(`Created tool ${base}`);
|
|
116
|
+
context.logger.blank();
|
|
117
|
+
context.logger.info(settings.giveOnSpawn
|
|
118
|
+
? `Every player gets the ${base} when they spawn.`
|
|
119
|
+
: `The ${base} is not given automatically: settings are in ${sharedDirectory}/${constName}.ts.`);
|
|
120
|
+
context.logger.info(`Write what it does in ${componentsDirectory}/${constName}Component.ts (activate).`);
|
|
121
|
+
context.logger.info(`Change its settings later in ${sharedDirectory}/${constName}.ts.`);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=make-tool.js.map
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { RoworkError } from "../cli/errors.js";
|
|
3
|
+
import { resolveProjectPath } from "../core/config.js";
|
|
4
|
+
import { formatGenerated } from "../core/format-generated.js";
|
|
5
|
+
import { generateFile, toClassBase } from "../core/generate.js";
|
|
6
|
+
import { installModule } from "../core/modules.js";
|
|
7
|
+
import { ELEMENTS_MARKER, addElementToScreen, addScreenToApp, listScreens, screensDirectory, screensSupported, uiDirectory, } from "../core/ui-edit.js";
|
|
8
|
+
import { uiModule } from "../modules/ui.js";
|
|
9
|
+
import { defineCommand } from "../plugins/api.js";
|
|
10
|
+
import { answered, prompts } from "../ui/prompt.js";
|
|
11
|
+
import { nameOrAsk, requireProject } from "./make.js";
|
|
12
|
+
const NAME_ARGUMENT = { name: "name", description: "the name, e.g. Shop (omit it for the guided version)", required: false };
|
|
13
|
+
function read(path) {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(path, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Makes sure the project has an interface that screens can be added to. Guided, a missing
|
|
23
|
+
* `ui` module is offered and installed on the spot; scripted, the one command that fixes it
|
|
24
|
+
* is printed. A project whose interface predates screens is told exactly what to change.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureInterface(context, root, config, guided, command) {
|
|
27
|
+
if (!(config.modules ?? []).includes("ui")) {
|
|
28
|
+
if (!guided) {
|
|
29
|
+
throw new RoworkError("There is no interface to add this to.", { hint: "Run `rowork add:ui` first, then try again." });
|
|
30
|
+
}
|
|
31
|
+
const proceed = answered(await prompts.confirm({ message: "Your project has no interface yet. Add the UI module (React) now?", initialValue: true }));
|
|
32
|
+
if (!proceed) {
|
|
33
|
+
prompts.cancel("Nothing changed.");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
await installModule({ ...context, options: {} }, uiModule, root, false);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const state = screensSupported(root, config, read);
|
|
40
|
+
if (!state.ok) {
|
|
41
|
+
throw new RoworkError(`This project's interface predates screens: ${state.reason}.`, {
|
|
42
|
+
hint: [
|
|
43
|
+
`\`rowork ${command}\` adds to two files, and needs them to look like a new project's:`,
|
|
44
|
+
" - UiController.tsx draws with: createRoot(new Instance(\"Folder\")).render(createPortal(<App />, playerGui)); (import createPortal from \"@rbxts/react-roblox\")",
|
|
45
|
+
" - App.tsx returns a fragment listing the screens, with a `{/* rowork:screens */}` line after them.",
|
|
46
|
+
"See docs/modules.md, section \"User interface\", for the full example.",
|
|
47
|
+
].join("\n "),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const makeScreenCommand = defineCommand({
|
|
52
|
+
name: "make:screen",
|
|
53
|
+
description: "Create a screen (a ScreenGui) and show it from App, ready to fill with elements.",
|
|
54
|
+
guided: true,
|
|
55
|
+
arguments: [NAME_ARGUMENT],
|
|
56
|
+
async run(context) {
|
|
57
|
+
const { root, config } = requireProject(context, "make:screen");
|
|
58
|
+
const { name: typed, guided } = await nameOrAsk(context, "make:screen", "What is the screen called? (e.g. Shop, Inventory, Settings)", "Shop");
|
|
59
|
+
const base = toClassBase(typed);
|
|
60
|
+
const screenName = base.endsWith("Screen") && base.length > "Screen".length ? base.slice(0, -"Screen".length) : base;
|
|
61
|
+
const className = `${screenName}Screen`;
|
|
62
|
+
await ensureInterface(context, root, config, guided, "make:screen");
|
|
63
|
+
const screenFile = `${screensDirectory(config)}/${className}.tsx`;
|
|
64
|
+
if (existsSync(resolveProjectPath(root, screenFile))) {
|
|
65
|
+
throw new RoworkError(`${screenFile} already exists.`, {
|
|
66
|
+
hint: "Pick another name. To start this screen again, delete the file and its line in App.tsx.",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Computed before anything is written: a refusal leaves the project as it was.
|
|
70
|
+
const appFile = `${uiDirectory(config)}/App.tsx`;
|
|
71
|
+
const appPath = resolveProjectPath(root, appFile);
|
|
72
|
+
const appSource = read(appPath);
|
|
73
|
+
if (appSource === undefined)
|
|
74
|
+
throw new RoworkError(`Cannot read ${appFile}.`, { hint: "It is created by `rowork add:ui`." });
|
|
75
|
+
const newApp = addScreenToApp(appSource, appFile, className, `./screens/${className}`);
|
|
76
|
+
const written = generateFile({
|
|
77
|
+
projectRoot: root,
|
|
78
|
+
directory: screensDirectory(config),
|
|
79
|
+
fileName: `${className}.tsx`,
|
|
80
|
+
template: "screen.tsx",
|
|
81
|
+
variables: { name: screenName, className, elementsMarker: ELEMENTS_MARKER },
|
|
82
|
+
force: false,
|
|
83
|
+
});
|
|
84
|
+
writeFileSync(appPath, newApp, "utf8");
|
|
85
|
+
await formatGenerated(root, [appPath, resolveProjectPath(root, screenFile)], context.logger);
|
|
86
|
+
context.logger.success(`Created the ${screenName} screen (${written ?? screenFile})`);
|
|
87
|
+
context.logger.step(`${appFile}: shows <${className} />`);
|
|
88
|
+
context.logger.blank();
|
|
89
|
+
context.logger.info("Press Play in Studio: a placeholder title shows at the top of the screen, delete it when you add your own.");
|
|
90
|
+
context.logger.info(`Add things to it: rowork make:ui <name> --in ${screenName}`);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const KINDS = {
|
|
94
|
+
button: { label: "Button", hint: "text and a click", controls: '{ text: "Click me" }', binding: "text={props.controls.text}" },
|
|
95
|
+
label: { label: "Text", hint: "a piece of text", controls: '{ text: "Hello" }', binding: "text={props.controls.text}" },
|
|
96
|
+
panel: { label: "Panel", hint: "a rounded box with a title, that holds other elements", controls: '{ title: "Panel" }', binding: "title={props.controls.title}" },
|
|
97
|
+
image: { label: "Image", hint: "a picture (upload files with `rowork assets`)", controls: '{ image: "" }', binding: "image={props.controls.image}" },
|
|
98
|
+
};
|
|
99
|
+
export const makeUiCommand = defineCommand({
|
|
100
|
+
name: "make:ui",
|
|
101
|
+
description: "Create a reusable interface element (button, text, panel, image) with its Studio preview, and place it in a screen.",
|
|
102
|
+
guided: true,
|
|
103
|
+
arguments: [NAME_ARGUMENT],
|
|
104
|
+
options: [
|
|
105
|
+
{ flags: "--kind <kind>", description: "button (default), label, panel or image" },
|
|
106
|
+
{ flags: "--in <screen>", description: "place it in this screen, e.g. Home (see the screens in the ui folder)" },
|
|
107
|
+
],
|
|
108
|
+
async run(context) {
|
|
109
|
+
const { root, config } = requireProject(context, "make:ui");
|
|
110
|
+
const { name: typed, guided } = await nameOrAsk(context, "make:ui", "What is the element called? (e.g. HealthBar, ShopButton)", "ShopButton");
|
|
111
|
+
const name = toClassBase(typed);
|
|
112
|
+
let kind = "button";
|
|
113
|
+
const askedKind = context.options["kind"];
|
|
114
|
+
if (typeof askedKind === "string") {
|
|
115
|
+
if (!(askedKind in KINDS)) {
|
|
116
|
+
throw new RoworkError(`Unknown kind \`${askedKind}\`.`, { hint: "Use button, label, panel or image." });
|
|
117
|
+
}
|
|
118
|
+
kind = askedKind;
|
|
119
|
+
}
|
|
120
|
+
else if (guided) {
|
|
121
|
+
kind = answered(await prompts.select({
|
|
122
|
+
message: "What kind of element is it?",
|
|
123
|
+
options: Object.keys(KINDS).map((value) => ({ value, label: KINDS[value].label, hint: KINDS[value].hint })),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
await ensureInterface(context, root, config, guided, "make:ui");
|
|
127
|
+
// "Place it in...?": a screen to put it in, so it shows up without editing anything.
|
|
128
|
+
const screens = listScreens(root, config);
|
|
129
|
+
let placeIn;
|
|
130
|
+
if (typeof context.options["in"] === "string") {
|
|
131
|
+
const wanted = context.options["in"];
|
|
132
|
+
placeIn = screens.find((screen) => screen.toLowerCase() === wanted.replace(/Screen$/i, "").toLowerCase());
|
|
133
|
+
if (placeIn === undefined) {
|
|
134
|
+
throw new RoworkError(`There is no screen called \`${wanted}\`.`, {
|
|
135
|
+
hint: screens.length === 0 ? "Create one with `rowork make:screen <name>`." : `Screens: ${screens.join(", ")}.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (guided && screens.length > 0) {
|
|
140
|
+
const chosen = answered(await prompts.select({
|
|
141
|
+
message: "Put it in a screen now?",
|
|
142
|
+
options: [
|
|
143
|
+
...screens.map((screen) => ({ value: screen, label: screen })),
|
|
144
|
+
{ value: "", label: "Not yet", hint: "just create it, I will place it myself" },
|
|
145
|
+
],
|
|
146
|
+
}));
|
|
147
|
+
placeIn = chosen === "" ? undefined : chosen;
|
|
148
|
+
}
|
|
149
|
+
const uiDir = uiDirectory(config);
|
|
150
|
+
const componentFile = `${uiDir}/${name}.tsx`;
|
|
151
|
+
const storyFile = `${uiDir}/${name}.story.tsx`;
|
|
152
|
+
for (const file of [componentFile, storyFile]) {
|
|
153
|
+
if (existsSync(resolveProjectPath(root, file))) {
|
|
154
|
+
throw new RoworkError(`${file} already exists.`, { hint: "Pick another name." });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// The screen edit is computed first, so a refusal leaves the project as it was.
|
|
158
|
+
let screenEdit;
|
|
159
|
+
if (placeIn !== undefined) {
|
|
160
|
+
const file = `${screensDirectory(config)}/${placeIn}Screen.tsx`;
|
|
161
|
+
const path = resolveProjectPath(root, file);
|
|
162
|
+
const source = read(path);
|
|
163
|
+
if (source === undefined)
|
|
164
|
+
throw new RoworkError(`Cannot read ${file}.`);
|
|
165
|
+
screenEdit = { path, file, source: addElementToScreen(source, file, name, `../${name}`) };
|
|
166
|
+
}
|
|
167
|
+
const written = generateFile({
|
|
168
|
+
projectRoot: root,
|
|
169
|
+
directory: uiDir,
|
|
170
|
+
fileName: `${name}.tsx`,
|
|
171
|
+
template: `ui-${kind}.tsx`,
|
|
172
|
+
variables: { name },
|
|
173
|
+
force: false,
|
|
174
|
+
});
|
|
175
|
+
const story = generateFile({
|
|
176
|
+
projectRoot: root,
|
|
177
|
+
directory: uiDir,
|
|
178
|
+
fileName: `${name}.story.tsx`,
|
|
179
|
+
template: "ui-story.tsx",
|
|
180
|
+
variables: { name, controls: KINDS[kind].controls, binding: KINDS[kind].binding },
|
|
181
|
+
force: false,
|
|
182
|
+
});
|
|
183
|
+
if (screenEdit !== undefined)
|
|
184
|
+
writeFileSync(screenEdit.path, screenEdit.source, "utf8");
|
|
185
|
+
await formatGenerated(root, [resolveProjectPath(root, componentFile), resolveProjectPath(root, storyFile), ...(screenEdit === undefined ? [] : [screenEdit.path])], context.logger);
|
|
186
|
+
context.logger.success(`Created ${KINDS[kind].label.toLowerCase()} ${name} (${written ?? componentFile})`);
|
|
187
|
+
context.logger.step(`${story ?? storyFile}: its preview for the UI Labs plugin in Studio`);
|
|
188
|
+
if (screenEdit !== undefined) {
|
|
189
|
+
context.logger.step(`${screenEdit.file}: places <${name} />`);
|
|
190
|
+
context.logger.blank();
|
|
191
|
+
context.logger.info("Press Play in Studio to see it.");
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
context.logger.blank();
|
|
195
|
+
context.logger.info(`It is not shown anywhere yet. Place it in a screen with rowork make:ui ${name} --in <screen> (or write <${name} /> in a screen yourself).`);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
//# sourceMappingURL=make-ui.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CommandContext, type CommandDefinition, type RoworkConfig } from "../plugins/api.js";
|
|
2
|
+
export type Side = "server" | "client";
|
|
3
|
+
export declare function requireProject(context: CommandContext, command: string): {
|
|
4
|
+
root: string;
|
|
5
|
+
config: RoworkConfig;
|
|
6
|
+
};
|
|
7
|
+
/** Validates a name typed in a prompt, using the same rules as the command line. */
|
|
8
|
+
export declare function validateName(value: string | undefined): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The name from the command line, or, in a terminal with none given, from a
|
|
11
|
+
* question. `undefined` guided means the caller should also ask its other
|
|
12
|
+
* questions.
|
|
13
|
+
*/
|
|
14
|
+
export declare function nameOrAsk(context: CommandContext, command: string, message: string, placeholder: string): Promise<{
|
|
15
|
+
name: string;
|
|
16
|
+
guided: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
export declare const makeServiceCommand: CommandDefinition;
|
|
19
|
+
export declare const makeControllerCommand: CommandDefinition;
|
|
20
|
+
export declare const makeComponentCommand: CommandDefinition;
|
|
21
|
+
//# sourceMappingURL=make.d.ts.map
|