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,104 @@
|
|
|
1
|
+
import { readFileSync } 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 { answered, prompts } from "../ui/prompt.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reads the fields of the `PlayerData` interface the player-data module wrote.
|
|
8
|
+
* Deliberately a plain read of a file the user owns: if they reshaped it, the
|
|
9
|
+
* module says what it could not find instead of guessing.
|
|
10
|
+
*/
|
|
11
|
+
function readDataFields(projectRoot, dataFile) {
|
|
12
|
+
let source;
|
|
13
|
+
try {
|
|
14
|
+
source = readFileSync(resolveProjectPath(projectRoot, dataFile), "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new RoworkError(`Cannot read ${dataFile}.`, {
|
|
18
|
+
hint: "The leaderstats module shows values from PlayerData. Add it with `rowork add:player-data` first.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const body = /export interface PlayerData\s*{([^}]*)}/.exec(source)?.[1] ?? "";
|
|
22
|
+
const fields = [...body.matchAll(/^\s*(\w+)\s*:\s*(number|string|boolean)\s*;/gm)].map((match) => ({
|
|
23
|
+
name: match[1],
|
|
24
|
+
type: match[2],
|
|
25
|
+
}));
|
|
26
|
+
if (fields.length === 0) {
|
|
27
|
+
throw new RoworkError(`No number, text or yes/no field found in ${dataFile}.`, {
|
|
28
|
+
hint: "Expected fields like `coins: number;` inside `export interface PlayerData { ... }`.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return fields;
|
|
32
|
+
}
|
|
33
|
+
export const leaderstatsModule = {
|
|
34
|
+
name: "leaderstats",
|
|
35
|
+
title: "Leaderstats",
|
|
36
|
+
description: "Show chosen player data (coins, level...) in the in-game leaderboard, kept up to date.",
|
|
37
|
+
requires: ["player-data"],
|
|
38
|
+
agentGuide: [
|
|
39
|
+
"`LeaderstatsService` mirrors PlayerData into Roblox's leaderboard. To show another value, add its name to the `SHOWN` list at the top of the file.",
|
|
40
|
+
"The leaderboard is only a display: read values from `PlayerDataService`, never from the `leaderstats` folder.",
|
|
41
|
+
],
|
|
42
|
+
options: [
|
|
43
|
+
{
|
|
44
|
+
flags: "--stat <field...>",
|
|
45
|
+
description: "a PlayerData field to show in the leaderboard (repeatable)",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
async plan({ guided, options, config, projectRoot }) {
|
|
49
|
+
const dataFile = `${config.paths.shared}/data/PlayerData.ts`;
|
|
50
|
+
const fields = readDataFields(projectRoot, dataFile);
|
|
51
|
+
const names = fields.map((field) => field.name);
|
|
52
|
+
let shown;
|
|
53
|
+
if (guided) {
|
|
54
|
+
shown = answered(await prompts.multiselect({
|
|
55
|
+
message: "Which values show in the leaderboard?",
|
|
56
|
+
options: fields.map((field) => ({
|
|
57
|
+
value: field.name,
|
|
58
|
+
label: field.name,
|
|
59
|
+
hint: field.type,
|
|
60
|
+
})),
|
|
61
|
+
initialValues: fields.filter((field) => field.type === "number").map((field) => field.name),
|
|
62
|
+
required: true,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const given = options["stat"];
|
|
67
|
+
shown = Array.isArray(given)
|
|
68
|
+
? given.map(String)
|
|
69
|
+
: fields.filter((field) => field.type === "number").map((field) => field.name);
|
|
70
|
+
const unknown = shown.filter((name) => !names.includes(name));
|
|
71
|
+
if (unknown.length > 0) {
|
|
72
|
+
throw new RoworkError(`Unknown PlayerData field: ${unknown.join(", ")}.`, {
|
|
73
|
+
hint: `Available: ${names.join(", ")}.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (shown.length === 0) {
|
|
77
|
+
throw new RoworkError("Nothing to show.", {
|
|
78
|
+
hint: "Give at least one field, e.g. --stat coins.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const services = config.paths.services;
|
|
83
|
+
return {
|
|
84
|
+
files: [
|
|
85
|
+
{
|
|
86
|
+
template: "leaderstats/LeaderstatsService",
|
|
87
|
+
directory: services,
|
|
88
|
+
fileName: "LeaderstatsService.ts",
|
|
89
|
+
variables: {
|
|
90
|
+
dataImport: `@import:${config.paths.shared}/data/PlayerData`,
|
|
91
|
+
shown: shown.map((name) => JSON.stringify(name)).join(", "),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
register: [{ side: "server", directory: services }],
|
|
96
|
+
notes: [
|
|
97
|
+
`Showing in the leaderboard: ${shown.join(", ")}.`,
|
|
98
|
+
`To show another value, add its name to SHOWN in ${join(services, "LeaderstatsService.ts")}.`,
|
|
99
|
+
"Values follow PlayerDataService.update: nothing else to write.",
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
//# sourceMappingURL=leaderstats.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ModuleDefinition } from "./types.js";
|
|
2
|
+
/** ESLint 10 runs on Node 20.19+, 22.13+ or 24+. Rowork itself accepts any Node 20. */
|
|
3
|
+
export declare function nodeSupportsEslint(version?: string): boolean;
|
|
4
|
+
export declare const lintModule: ModuleDefinition;
|
|
5
|
+
//# sourceMappingURL=lint.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RoworkError } from "../cli/errors.js";
|
|
2
|
+
/** ESLint 10 runs on Node 20.19+, 22.13+ or 24+. Rowork itself accepts any Node 20. */
|
|
3
|
+
export function nodeSupportsEslint(version = process.versions.node) {
|
|
4
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
5
|
+
return (major === 20 && minor >= 19) || (major === 22 && minor >= 13) || major >= 24;
|
|
6
|
+
}
|
|
7
|
+
export const lintModule = {
|
|
8
|
+
name: "lint",
|
|
9
|
+
title: "Linter",
|
|
10
|
+
description: "Catch roblox-ts mistakes (any, null, unsupported code) early, with ESLint and the official roblox-ts rules.",
|
|
11
|
+
dependencies: ["eslint", "@typescript-eslint/parser", "eslint-plugin-roblox-ts"],
|
|
12
|
+
agentGuide: [
|
|
13
|
+
"Run `npm run lint` (and `npm run lint:fix` for what can be fixed automatically) before considering a change done, in addition to `npm run build`.",
|
|
14
|
+
"The rules are the official roblox-ts ones: no `any`, no `null` (use `undefined`), and code Luau cannot express. Fix the code rather than disabling a rule. Settings live in `eslint.config.mjs`.",
|
|
15
|
+
],
|
|
16
|
+
plan() {
|
|
17
|
+
if (!nodeSupportsEslint()) {
|
|
18
|
+
throw new RoworkError(`The linter needs a newer Node than ${process.versions.node}.`, {
|
|
19
|
+
hint: "ESLint 10 runs on Node 20.19+, 22.13+ or 24+. Update Node, then run `rowork add lint`.",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
files: [
|
|
24
|
+
{
|
|
25
|
+
template: "lint/eslint.config.mjs",
|
|
26
|
+
directory: ".",
|
|
27
|
+
fileName: "eslint.config.mjs",
|
|
28
|
+
variables: {},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
register: [],
|
|
32
|
+
scripts: {
|
|
33
|
+
lint: "eslint src",
|
|
34
|
+
"lint:fix": "eslint src --fix",
|
|
35
|
+
},
|
|
36
|
+
notes: [
|
|
37
|
+
"Check your code: npm run lint Fix what can be fixed: npm run lint:fix",
|
|
38
|
+
"The rules are the official roblox-ts ones (no `any`, no `null`...). Change them in eslint.config.mjs.",
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=lint.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ModuleDefinition } from "./types.js";
|
|
2
|
+
type Direction = "server" | "client";
|
|
3
|
+
interface NetworkEvent {
|
|
4
|
+
name: string;
|
|
5
|
+
/** `server`: the client sends it to the server. `client`: the server sends it to clients. */
|
|
6
|
+
direction: Direction;
|
|
7
|
+
/** TypeScript parameter list, e.g. `itemId: string, amount: number`. */
|
|
8
|
+
parameters: string;
|
|
9
|
+
}
|
|
10
|
+
/** Why a parameter list is not valid, or undefined when it is. */
|
|
11
|
+
export declare function parameterProblem(text: string): string | undefined;
|
|
12
|
+
export declare function checkParameters(parameters: string): string;
|
|
13
|
+
/** Parses `buyItem:server(itemId: string, amount: number)`. */
|
|
14
|
+
export declare function parseEvent(spec: string): NetworkEvent;
|
|
15
|
+
export declare const networkingModule: ModuleDefinition;
|
|
16
|
+
/**
|
|
17
|
+
* Asks what an event carries, one thing at a time, without any syntax to learn:
|
|
18
|
+
* a name, then a kind picked from a list. Returns what the networking file needs,
|
|
19
|
+
* like `itemId: string, amount: number` (or an empty string for nothing).
|
|
20
|
+
*/
|
|
21
|
+
export declare function askEventParameters(eventName: string): Promise<string>;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=networking.d.ts.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { RoworkError } from "../cli/errors.js";
|
|
2
|
+
import { answered, prompts } from "../ui/prompt.js";
|
|
3
|
+
import { toFieldName } from "./player-data.js";
|
|
4
|
+
const IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
5
|
+
/** Enough for real types, too little to break out of the interface. */
|
|
6
|
+
const SAFE_PARAMETERS = /^[A-Za-z0-9_:,<>[\]\s|?.'"]*$/;
|
|
7
|
+
const PARAMETER = /^[A-Za-z_][A-Za-z0-9_]*\??\s*:\s*\S.*$/;
|
|
8
|
+
/** Splits on commas that are not inside `<>` or `[]`, so `Map<string, number>` stays whole. */
|
|
9
|
+
function splitParameters(text) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
let depth = 0;
|
|
12
|
+
let current = "";
|
|
13
|
+
for (const character of text) {
|
|
14
|
+
if (character === "<" || character === "[")
|
|
15
|
+
depth += 1;
|
|
16
|
+
if (character === ">" || character === "]")
|
|
17
|
+
depth -= 1;
|
|
18
|
+
if (character === "," && depth === 0) {
|
|
19
|
+
parts.push(current.trim());
|
|
20
|
+
current = "";
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
current += character;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (current.trim() !== "")
|
|
27
|
+
parts.push(current.trim());
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
30
|
+
/** Why a parameter list is not valid, or undefined when it is. */
|
|
31
|
+
export function parameterProblem(text) {
|
|
32
|
+
const trimmed = text.trim();
|
|
33
|
+
if (trimmed === "")
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!SAFE_PARAMETERS.test(trimmed))
|
|
36
|
+
return "Only plain TypeScript types: letters, digits and : , < > [ ] | ?";
|
|
37
|
+
for (const part of splitParameters(trimmed)) {
|
|
38
|
+
if (!PARAMETER.test(part)) {
|
|
39
|
+
return `\`${part}\` is not \`name: type\`. The name is one word, like itemId: string`;
|
|
40
|
+
}
|
|
41
|
+
const type = part.slice(part.indexOf(":") + 1).trim();
|
|
42
|
+
if (!/^[A-Za-z_"'[{(]/.test(type)) {
|
|
43
|
+
return `\`${type}\` is not a type. Use string (text), number or boolean (yes/no)`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
export function checkParameters(parameters) {
|
|
49
|
+
const problem = parameterProblem(parameters);
|
|
50
|
+
if (problem !== undefined) {
|
|
51
|
+
throw new RoworkError(`Invalid arguments \`${parameters.trim()}\`.`, { hint: problem });
|
|
52
|
+
}
|
|
53
|
+
return parameters.trim();
|
|
54
|
+
}
|
|
55
|
+
/** Parses `buyItem:server(itemId: string, amount: number)`. */
|
|
56
|
+
export function parseEvent(spec) {
|
|
57
|
+
const match = /^\s*([^:()]+):(server|client)\s*(?:\((.*)\))?\s*$/.exec(spec);
|
|
58
|
+
const name = match?.[1] === undefined ? undefined : toFieldName(match[1]);
|
|
59
|
+
if (match === null || name === undefined || !IDENTIFIER.test(name)) {
|
|
60
|
+
throw new RoworkError(`\`${spec}\` is not a valid event.`, {
|
|
61
|
+
hint: "Write name:direction(arguments), e.g. buyItem:server(itemId: string). `server` = the client sends it to the server, `client` = the server sends it to clients.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
direction: match[2],
|
|
67
|
+
parameters: checkParameters(match[3] ?? ""),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function askEvents() {
|
|
71
|
+
const events = [];
|
|
72
|
+
for (;;) {
|
|
73
|
+
const first = events.length === 0;
|
|
74
|
+
const raw = answered(await prompts.text({
|
|
75
|
+
message: first
|
|
76
|
+
? "Name of a message between client and server (anything you like, e.g. buy item)"
|
|
77
|
+
: "Another one? Type its name, or leave empty when you are done",
|
|
78
|
+
placeholder: first ? "buy item" : "",
|
|
79
|
+
validate: (value) => {
|
|
80
|
+
if (value === undefined || value.trim() === "")
|
|
81
|
+
return first ? "Type a name." : undefined;
|
|
82
|
+
const name = toFieldName(value);
|
|
83
|
+
if (!IDENTIFIER.test(name))
|
|
84
|
+
return "Use letters and digits, starting with a letter.";
|
|
85
|
+
if (events.some((event) => event.name === name))
|
|
86
|
+
return `\`${name}\` is already there.`;
|
|
87
|
+
return undefined;
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
if (raw.trim() === "")
|
|
91
|
+
return events;
|
|
92
|
+
const name = toFieldName(raw);
|
|
93
|
+
const direction = answered(await prompts.select({
|
|
94
|
+
message: `Who sends ${name}?`,
|
|
95
|
+
options: [
|
|
96
|
+
{ value: "server", label: "The player's client, to the server", hint: "a button click, a purchase" },
|
|
97
|
+
{ value: "client", label: "The server, to the player(s)", hint: "a notification, a result" },
|
|
98
|
+
],
|
|
99
|
+
}));
|
|
100
|
+
const parameters = await askEventParameters(name);
|
|
101
|
+
events.push({ name, direction, parameters });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function lines(events, direction) {
|
|
105
|
+
const matching = events.filter((event) => event.direction === direction);
|
|
106
|
+
return matching.length === 0
|
|
107
|
+
? "\t// (none yet)"
|
|
108
|
+
: matching.map((event) => `\t${event.name}(${event.parameters}): void;`).join("\n");
|
|
109
|
+
}
|
|
110
|
+
export const networkingModule = {
|
|
111
|
+
name: "networking",
|
|
112
|
+
title: "Typed networking",
|
|
113
|
+
description: "Messages between client and server with types, no RemoteEvent to create or wire by hand.",
|
|
114
|
+
dependencies: ["@flamework/networking"],
|
|
115
|
+
agentGuide: [
|
|
116
|
+
"Every client/server message is declared once in `src/shared/networking.ts`, in `ClientToServerEvents` or `ServerToClientEvents`. To add one, add a line such as `buyItem(itemId: string, amount: number): void;`. Do not create RemoteEvents by hand.",
|
|
117
|
+
"Server: `import { Events } from \"../network\"`, then `Events.name.connect((player, ...args) => {})`, `Events.name.fire(player, ...args)`, `Events.name.broadcast(...args)`. Client: `Events.name.fire(...args)` and `Events.name.connect((...args) => {})`.",
|
|
118
|
+
"On the server the sender is always the first argument and comes from Roblox: never take a player from the arguments.",
|
|
119
|
+
"Every event the client sends is rate limited per player (`limit()` in `src/server/network.ts`, settings in `src/server/rateLimit.ts`): `make:event` adds the line for you. Never remove it to fix a bug; raise the number for that event instead, e.g. `limit(60)`.",
|
|
120
|
+
"Flamework already checks at runtime that the arguments a client sends have the declared types; do not add manual type checks for that.",
|
|
121
|
+
],
|
|
122
|
+
options: [
|
|
123
|
+
{
|
|
124
|
+
flags: "--event <spec...>",
|
|
125
|
+
description: 'a message, as name:direction(arguments), e.g. "buyItem:server(itemId: string)" (repeatable)',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
async plan({ guided, options, config }) {
|
|
129
|
+
let events;
|
|
130
|
+
if (guided) {
|
|
131
|
+
events = await askEvents();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const given = options["event"];
|
|
135
|
+
events = Array.isArray(given) ? given.map((spec) => parseEvent(String(spec))) : [];
|
|
136
|
+
}
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const event of events) {
|
|
139
|
+
if (seen.has(event.name))
|
|
140
|
+
throw new RoworkError(`Event \`${event.name}\` is listed twice.`);
|
|
141
|
+
seen.add(event.name);
|
|
142
|
+
}
|
|
143
|
+
const shared = config.paths.shared;
|
|
144
|
+
const networkingFile = `${shared}/networking`;
|
|
145
|
+
const example = events[0];
|
|
146
|
+
return {
|
|
147
|
+
files: [
|
|
148
|
+
{
|
|
149
|
+
template: "networking/networking",
|
|
150
|
+
directory: shared,
|
|
151
|
+
fileName: "networking.ts",
|
|
152
|
+
variables: { toServer: lines(events, "server"), toClient: lines(events, "client") },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
template: "networking/rate-limit",
|
|
156
|
+
directory: `${config.paths.source}/server`,
|
|
157
|
+
fileName: "rateLimit.ts",
|
|
158
|
+
variables: {},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
template: "networking/server-network",
|
|
162
|
+
directory: `${config.paths.source}/server`,
|
|
163
|
+
fileName: "network.ts",
|
|
164
|
+
variables: {
|
|
165
|
+
networkingImport: `@import:${networkingFile}`,
|
|
166
|
+
rateLimitImport: `@import:${config.paths.source}/server/rateLimit`,
|
|
167
|
+
// One line per event the client sends: those are the ones a cheater can flood.
|
|
168
|
+
entries: events
|
|
169
|
+
.filter((event) => event.direction === "server")
|
|
170
|
+
.map((event) => `\n\t\t${event.name}: [limit()],`)
|
|
171
|
+
.join(""),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
template: "networking/client-network",
|
|
176
|
+
directory: `${config.paths.source}/client`,
|
|
177
|
+
fileName: "network.ts",
|
|
178
|
+
variables: { networkingImport: `@import:${networkingFile}` },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
register: [],
|
|
182
|
+
notes: [
|
|
183
|
+
events.length === 0
|
|
184
|
+
? `No event yet: add one in ${networkingFile}.ts (see the comment at the top).`
|
|
185
|
+
: `Events: ${events.map((event) => `${event.name} (${event.direction === "server" ? "client to server" : "server to client"})`).join(", ")}.`,
|
|
186
|
+
`Server: import { Events } from "./network"; Client: import { Events } from "./network";`,
|
|
187
|
+
example === undefined
|
|
188
|
+
? "Add events in the two interfaces of src/shared/networking.ts."
|
|
189
|
+
: example.direction === "server"
|
|
190
|
+
? `Server listens: Events.${example.name}.connect((player${example.parameters === "" ? "" : ", ..."}) => { ... }); Client sends: Events.${example.name}.fire(${example.parameters === "" ? "" : "..."});`
|
|
191
|
+
: `Client listens: Events.${example.name}.connect((${example.parameters === "" ? "" : "..."}) => { ... }); Server sends: Events.${example.name}.fire(player${example.parameters === "" ? "" : ", ..."});`,
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
const KNOWN_TYPES = [
|
|
197
|
+
{ value: "string", label: "Text", hint: "an item name, a message" },
|
|
198
|
+
{ value: "number", label: "Number", hint: "an amount, an id" },
|
|
199
|
+
{ value: "boolean", label: "Yes / no", hint: "true or false" },
|
|
200
|
+
{ value: "other", label: "Other...", hint: "Player, Vector3, a list... type it yourself" },
|
|
201
|
+
];
|
|
202
|
+
/**
|
|
203
|
+
* Asks what an event carries, one thing at a time, without any syntax to learn:
|
|
204
|
+
* a name, then a kind picked from a list. Returns what the networking file needs,
|
|
205
|
+
* like `itemId: string, amount: number` (or an empty string for nothing).
|
|
206
|
+
*/
|
|
207
|
+
export async function askEventParameters(eventName) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (;;) {
|
|
210
|
+
const first = parts.length === 0;
|
|
211
|
+
const raw = answered(await prompts.text({
|
|
212
|
+
message: first
|
|
213
|
+
? `Does ${eventName} carry any information? Name one thing (like itemId, or amount). Leave empty if it carries nothing.`
|
|
214
|
+
: "Anything else? Name it, or leave empty when that is all.",
|
|
215
|
+
placeholder: first ? "itemId" : "",
|
|
216
|
+
defaultValue: "",
|
|
217
|
+
validate: (value) => {
|
|
218
|
+
if (value === undefined || value.trim() === "")
|
|
219
|
+
return undefined;
|
|
220
|
+
const name = toFieldName(value);
|
|
221
|
+
if (!IDENTIFIER.test(name))
|
|
222
|
+
return "Use letters and digits, starting with a letter.";
|
|
223
|
+
if (parts.some((part) => part.startsWith(`${name}:`)))
|
|
224
|
+
return `\`${name}\` is already there.`;
|
|
225
|
+
return undefined;
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
228
|
+
if (raw.trim() === "")
|
|
229
|
+
return parts.join(", ");
|
|
230
|
+
const name = toFieldName(raw);
|
|
231
|
+
const kind = answered(await prompts.select({
|
|
232
|
+
message: `What kind of information is ${name}?`,
|
|
233
|
+
options: KNOWN_TYPES.map((option) => ({ ...option })),
|
|
234
|
+
}));
|
|
235
|
+
let type = kind;
|
|
236
|
+
if (kind === "other") {
|
|
237
|
+
type = answered(await prompts.text({
|
|
238
|
+
message: `Type the type of ${name}, the way TypeScript writes it (like Player, Vector3, string[])`,
|
|
239
|
+
placeholder: "Player",
|
|
240
|
+
validate: (value) => parameterProblem(`${name}: ${value ?? ""}`),
|
|
241
|
+
})).trim();
|
|
242
|
+
}
|
|
243
|
+
parts.push(`${name}: ${type}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=networking.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ModuleDefinition } from "./types.js";
|
|
2
|
+
type FieldType = "number" | "string" | "boolean";
|
|
3
|
+
interface Field {
|
|
4
|
+
name: string;
|
|
5
|
+
type: FieldType;
|
|
6
|
+
/** The default, already written as source code. */
|
|
7
|
+
defaultValue: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function defaultFor(type: FieldType, raw: string | undefined): string;
|
|
10
|
+
/** Parses `name:type=default`, e.g. `coins:number=0` or `nickname:string=Guest`. */
|
|
11
|
+
export declare function parseField(spec: string): Field;
|
|
12
|
+
/**
|
|
13
|
+
* Turns whatever the person typed into a valid field name:
|
|
14
|
+
* `Best Score` and `best-score` both become `bestScore`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toFieldName(raw: string): string;
|
|
17
|
+
export declare const playerDataModule: ModuleDefinition;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=player-data.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { RoworkError } from "../cli/errors.js";
|
|
2
|
+
import { answered, prompts } from "../ui/prompt.js";
|
|
3
|
+
/** Suggestions offered by the guided version: almost every game wants these. */
|
|
4
|
+
const COMMON_FIELDS = [
|
|
5
|
+
{ name: "coins", type: "number", defaultValue: "0" },
|
|
6
|
+
{ name: "level", type: "number", defaultValue: "1" },
|
|
7
|
+
{ name: "xp", type: "number", defaultValue: "0" },
|
|
8
|
+
];
|
|
9
|
+
const IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
10
|
+
export function defaultFor(type, raw) {
|
|
11
|
+
if (type === "number") {
|
|
12
|
+
const value = raw === undefined || raw === "" ? 0 : Number(raw);
|
|
13
|
+
if (!Number.isFinite(value))
|
|
14
|
+
throw new RoworkError(`\`${String(raw)}\` is not a number.`);
|
|
15
|
+
return String(value);
|
|
16
|
+
}
|
|
17
|
+
if (type === "boolean")
|
|
18
|
+
return raw === "true" ? "true" : "false";
|
|
19
|
+
return JSON.stringify(raw ?? "");
|
|
20
|
+
}
|
|
21
|
+
/** Parses `name:type=default`, e.g. `coins:number=0` or `nickname:string=Guest`. */
|
|
22
|
+
export function parseField(spec) {
|
|
23
|
+
const match = /^([^:=]+)(?::(number|string|boolean))?(?:=(.*))?$/.exec(spec.trim());
|
|
24
|
+
const name = match?.[1] === undefined ? undefined : toFieldName(match[1]);
|
|
25
|
+
if (match === null || name === undefined || !IDENTIFIER.test(name)) {
|
|
26
|
+
throw new RoworkError(`\`${spec}\` is not a valid field.`, {
|
|
27
|
+
hint: "Write name:type=default, e.g. coins:number=0. The name uses letters and digits and starts with a letter.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const type = (match[2] ?? "number");
|
|
31
|
+
return { name, type, defaultValue: defaultFor(type, match[3]) };
|
|
32
|
+
}
|
|
33
|
+
const OTHER = "__other__";
|
|
34
|
+
/**
|
|
35
|
+
* Turns whatever the person typed into a valid field name:
|
|
36
|
+
* `Best Score` and `best-score` both become `bestScore`.
|
|
37
|
+
*/
|
|
38
|
+
export function toFieldName(raw) {
|
|
39
|
+
const words = raw.split(/[^A-Za-z0-9]+/).filter((word) => word.length > 0);
|
|
40
|
+
return words
|
|
41
|
+
.map((word, index) => index === 0
|
|
42
|
+
? word.charAt(0).toLowerCase() + word.slice(1)
|
|
43
|
+
: word.charAt(0).toUpperCase() + word.slice(1))
|
|
44
|
+
.join("");
|
|
45
|
+
}
|
|
46
|
+
async function askCustomField(taken, first) {
|
|
47
|
+
const raw = answered(await prompts.text({
|
|
48
|
+
message: first
|
|
49
|
+
? "Name of the value to save (anything you like, e.g. kills or best score)"
|
|
50
|
+
: "Another one? Type its name, or leave empty when you are done",
|
|
51
|
+
placeholder: first ? "kills" : "",
|
|
52
|
+
validate: (value) => {
|
|
53
|
+
if (first && (value === undefined || value.trim() === ""))
|
|
54
|
+
return "Type a name.";
|
|
55
|
+
if (value === undefined || value.trim() === "")
|
|
56
|
+
return undefined;
|
|
57
|
+
const name = toFieldName(value);
|
|
58
|
+
if (!IDENTIFIER.test(name))
|
|
59
|
+
return "Use letters and digits, starting with a letter.";
|
|
60
|
+
if (taken.some((field) => field.name === name))
|
|
61
|
+
return `\`${name}\` is already there.`;
|
|
62
|
+
return undefined;
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
if (raw.trim() === "")
|
|
66
|
+
return undefined;
|
|
67
|
+
const name = toFieldName(raw);
|
|
68
|
+
const type = answered(await prompts.select({
|
|
69
|
+
message: `What kind of value is ${name}?`,
|
|
70
|
+
options: [
|
|
71
|
+
{ value: "number", label: "Number", hint: "coins, kills, level" },
|
|
72
|
+
{ value: "string", label: "Text", hint: "a nickname, a title" },
|
|
73
|
+
{ value: "boolean", label: "Yes / no", hint: "has finished the tutorial" },
|
|
74
|
+
],
|
|
75
|
+
}));
|
|
76
|
+
const initial = answered(await prompts.text({
|
|
77
|
+
message: `Starting value of ${name}?`,
|
|
78
|
+
placeholder: type === "number" ? "0" : type === "boolean" ? "false" : "",
|
|
79
|
+
defaultValue: "",
|
|
80
|
+
}));
|
|
81
|
+
return { name, type, defaultValue: defaultFor(type, initial) };
|
|
82
|
+
}
|
|
83
|
+
async function askFields() {
|
|
84
|
+
const chosen = answered(await prompts.multiselect({
|
|
85
|
+
message: "What do you want to save for each player? (space to tick, enter to confirm)",
|
|
86
|
+
options: [
|
|
87
|
+
...COMMON_FIELDS.map((field) => ({ value: field.name, label: field.name })),
|
|
88
|
+
{ value: OTHER, label: "Other...", hint: "type your own, with the name you want" },
|
|
89
|
+
],
|
|
90
|
+
initialValues: ["coins", "level"],
|
|
91
|
+
required: true,
|
|
92
|
+
}));
|
|
93
|
+
const fields = COMMON_FIELDS.filter((field) => chosen.includes(field.name));
|
|
94
|
+
if (chosen.includes(OTHER)) {
|
|
95
|
+
let first = true;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const field = await askCustomField(fields, first);
|
|
98
|
+
if (field === undefined)
|
|
99
|
+
break;
|
|
100
|
+
fields.push(field);
|
|
101
|
+
first = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return fields;
|
|
105
|
+
}
|
|
106
|
+
function fieldsFromOptions(options) {
|
|
107
|
+
const specs = options["field"];
|
|
108
|
+
if (!Array.isArray(specs) || specs.length === 0) {
|
|
109
|
+
return COMMON_FIELDS.filter((field) => field.name !== "xp");
|
|
110
|
+
}
|
|
111
|
+
return specs.map((spec) => parseField(String(spec)));
|
|
112
|
+
}
|
|
113
|
+
export const playerDataModule = {
|
|
114
|
+
name: "player-data",
|
|
115
|
+
title: "Player data",
|
|
116
|
+
description: "Save and load each player's progress (coins, level...) so it survives leaving the game.",
|
|
117
|
+
dependencies: ["@rbxts/lapis", "@rbxts/t"],
|
|
118
|
+
agentGuide: [
|
|
119
|
+
"Saved data lives in `PlayerDataService` (server). From another service: inject it and call `get(player)`, `update(player, (data) => { data.coins += 1; return data; })`, `onLoaded(cb)`, `onChanged(cb)`.",
|
|
120
|
+
"To save a new value, add it in TWO places in `src/shared/data/PlayerData.ts`: the `PlayerData` interface and `DEFAULT_PLAYER_DATA`. Do not read data from the client.",
|
|
121
|
+
"Data is undefined until it has loaded: always handle that case.",
|
|
122
|
+
],
|
|
123
|
+
options: [
|
|
124
|
+
{
|
|
125
|
+
flags: "--field <spec...>",
|
|
126
|
+
description: "a value to save, as name:type=default, e.g. coins:number=0 (repeatable)",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
async plan({ guided, options, config }) {
|
|
130
|
+
const fields = guided ? await askFields() : fieldsFromOptions(options);
|
|
131
|
+
if (fields.length === 0) {
|
|
132
|
+
throw new RoworkError("Nothing to save: add at least one field.", {
|
|
133
|
+
hint: "Example: --field coins:number=0",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const field of fields) {
|
|
138
|
+
if (seen.has(field.name))
|
|
139
|
+
throw new RoworkError(`Field \`${field.name}\` is listed twice.`);
|
|
140
|
+
seen.add(field.name);
|
|
141
|
+
}
|
|
142
|
+
const dataDirectory = `${config.paths.shared}/data`;
|
|
143
|
+
const dataImport = `${dataDirectory}/PlayerData`;
|
|
144
|
+
return {
|
|
145
|
+
files: [
|
|
146
|
+
{
|
|
147
|
+
template: "player-data/PlayerData",
|
|
148
|
+
directory: dataDirectory,
|
|
149
|
+
fileName: "PlayerData.ts",
|
|
150
|
+
variables: {
|
|
151
|
+
interfaceFields: fields.map((field) => `\t${field.name}: ${field.type};`).join("\n"),
|
|
152
|
+
defaultFields: fields.map((field) => `\t${field.name}: ${field.defaultValue},`).join("\n"),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
template: "player-data/PlayerDataService",
|
|
157
|
+
directory: config.paths.services,
|
|
158
|
+
fileName: "PlayerDataService.ts",
|
|
159
|
+
// Resolved by the installer, which knows the project root.
|
|
160
|
+
variables: { dataImport: `@import:${dataImport}` },
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
register: [{ side: "server", directory: config.paths.services }],
|
|
164
|
+
notes: [
|
|
165
|
+
`Saving: ${fields.map((field) => field.name).join(", ")}.`,
|
|
166
|
+
`To save something new, add it to ${dataImport}.ts (two places, both in that file).`,
|
|
167
|
+
"Read and change a player's data from any service:",
|
|
168
|
+
" const data = this.playerData.get(player);",
|
|
169
|
+
" this.playerData.update(player, (data) => { data.coins += 10; return data; });",
|
|
170
|
+
"Testing in Studio: data is only really saved once the place is published and",
|
|
171
|
+
"'Enable Studio Access to API Services' is on. Otherwise it is kept in memory.",
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
//# sourceMappingURL=player-data.js.map
|