This commit is contained in:
86
scripts/api-server-maincloud.mjs
Normal file
86
scripts/api-server-maincloud.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function loadEnvFile(path, target) {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = readFileSync(path, 'utf8');
|
||||
for (const rawLine of rawText.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, key, rawValue] = match;
|
||||
if (target[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
}
|
||||
}
|
||||
|
||||
const mergedEnv = {...process.env};
|
||||
loadEnvFile(resolve(repoRoot, '.env'), mergedEnv);
|
||||
loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv);
|
||||
|
||||
mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1';
|
||||
mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100';
|
||||
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL =
|
||||
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL ||
|
||||
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL ||
|
||||
'https://maincloud.spacetimedb.com';
|
||||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
|
||||
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE ||
|
||||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE ||
|
||||
'';
|
||||
mergedEnv.GENARRATIVE_SPACETIME_TOKEN =
|
||||
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN ||
|
||||
mergedEnv.GENARRATIVE_SPACETIME_TOKEN ||
|
||||
'';
|
||||
|
||||
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
|
||||
console.error(
|
||||
'[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
'cargo',
|
||||
['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[api-server:maincloud] 启动 cargo 失败: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[api-server:maincloud] api-server 被信号终止: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
40
scripts/build-gate.mjs
Normal file
40
scripts/build-gate.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const args = [viteCliPath, 'build', ...process.argv.slice(2)];
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
];
|
||||
|
||||
const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
|
||||
if (warningLines.length > 0) {
|
||||
console.error('Build gate failed because warnings were emitted:');
|
||||
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
|
||||
process.exit(1);
|
||||
}
|
||||
173
scripts/check-encoding.mjs
Normal file
173
scripts/check-encoding.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.cjs',
|
||||
'.controller',
|
||||
'.css',
|
||||
'.env',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.md',
|
||||
'.meta',
|
||||
'.mjs',
|
||||
'.ps1',
|
||||
'.py',
|
||||
'.rs',
|
||||
'.scss',
|
||||
'.sh',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
|
||||
const TEXT_FILENAMES = new Set([
|
||||
'.editorconfig',
|
||||
'.gitattributes',
|
||||
'.gitignore',
|
||||
'.prettierignore',
|
||||
'.prettierrc',
|
||||
'.prettierrc.json',
|
||||
'AGENTS.md',
|
||||
'README.md',
|
||||
]);
|
||||
|
||||
const EXCLUDED_PREFIXES = [
|
||||
'.codex-logs/',
|
||||
'.git/',
|
||||
'.codex-cargo-home-',
|
||||
'dist/',
|
||||
'dist_check/',
|
||||
'dist_check_monster_position/',
|
||||
'media/',
|
||||
'node_modules/',
|
||||
'public/Icons/',
|
||||
'server-rs-codex-',
|
||||
'server-rs/target-',
|
||||
];
|
||||
|
||||
const IGNORE_FILE = '.encoding-check-ignore';
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function shouldCheck(filePath) {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
|
||||
// 本地 cargo cache / verify copy 不属于主工程源码,避免把临时工作区扫进仓库级编码检查。
|
||||
if (EXCLUDED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = basename(normalizedPath);
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
|
||||
if (TEXT_FILENAMES.has(fileName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fileName.startsWith('.env')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return TEXT_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
function listFilesFromGit() {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }
|
||||
);
|
||||
|
||||
return output
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.map(normalizePath)
|
||||
.filter(shouldCheck);
|
||||
}
|
||||
|
||||
function loadIgnoreList() {
|
||||
if (!existsSync(IGNORE_FILE)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
readFileSync(IGNORE_FILE, 'utf8')
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '' && !line.startsWith('#'))
|
||||
.map(normalizePath)
|
||||
);
|
||||
}
|
||||
|
||||
function hasNullByte(buffer) {
|
||||
for (const byte of buffer) {
|
||||
if (byte === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateUtf8(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(filePath);
|
||||
|
||||
if (hasNullByte(bytes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text;
|
||||
|
||||
try {
|
||||
text = decoder.decode(bytes);
|
||||
} catch {
|
||||
return `${filePath} is not valid UTF-8.`;
|
||||
}
|
||||
|
||||
if (text.includes('\uFFFD')) {
|
||||
return `${filePath} contains Unicode replacement characters (U+FFFD), which usually means text was already decoded incorrectly before being saved.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitFiles = process.argv.slice(2).map(normalizePath);
|
||||
const ignoreList = loadIgnoreList();
|
||||
const filesToCheck = (explicitFiles.length ? explicitFiles : listFilesFromGit())
|
||||
.filter(shouldCheck)
|
||||
.filter((filePath) => !ignoreList.has(filePath));
|
||||
const failures = [];
|
||||
|
||||
for (const filePath of filesToCheck) {
|
||||
const failure = validateUtf8(filePath);
|
||||
|
||||
if (failure) {
|
||||
failures.push(failure);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Encoding check failed:');
|
||||
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Encoding check passed for ${filesToCheck.length} file(s).`);
|
||||
724
scripts/deploy-rust-remote.sh
Normal file
724
scripts/deploy-rust-remote.sh
Normal file
@@ -0,0 +1,724 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
用法:
|
||||
npm run deploy:rust:remote
|
||||
./scripts/deploy-rust-remote.sh --name 20260422-153000
|
||||
|
||||
说明:
|
||||
1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。
|
||||
2. 使用 Vite 构建前端 release 到目标目录的 web/。
|
||||
3. 构建 api-server 的 x86_64-unknown-linux-gnu release,并复制到目标目录。
|
||||
4. 构建 spacetime-module 的 wasm32-unknown-unknown release,并复制 wasm 到目标目录。
|
||||
5. 在目标目录生成 start.sh / stop.sh,用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。
|
||||
|
||||
常用参数:
|
||||
--name <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
|
||||
--database <database> SpacetimeDB database,默认 ${DATABASE}
|
||||
--api-port <port> api-server 端口,默认 ${API_PORT}
|
||||
--web-port <port> 静态网站端口,默认 ${WEB_PORT}
|
||||
--spacetime-port <port> SpacetimeDB 端口,默认 ${SPACETIME_PORT}
|
||||
--ssh-key <path> 上传使用的 SSH 私钥,默认 ${SSH_KEY}
|
||||
--remote <user@host> 上传目标 SSH 主机,默认 ${REMOTE_TARGET}
|
||||
--remote-dir <path> 上传目标目录,默认 ${REMOTE_DIR}
|
||||
--skip-upload 只生成本地发布包,不上传服务器
|
||||
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||||
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试
|
||||
|
||||
目标服务器要求:
|
||||
Ubuntu x86_64,已安装 node、spacetime CLI,并允许执行目标目录内的 start.sh / stop.sh。
|
||||
如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "[deploy:rust] 缺少命令: ${command_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
copy_required_file() {
|
||||
local source_path="$1"
|
||||
local target_path="$2"
|
||||
local label="$3"
|
||||
|
||||
if [[ ! -f "${source_path}" ]]; then
|
||||
echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "${source_path}" "${target_path}"
|
||||
}
|
||||
|
||||
normalize_env_file() {
|
||||
local env_file="$1"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
|
||||
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
|
||||
mv "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
copy_optional_file() {
|
||||
local source_path="$1"
|
||||
local target_path_a="$2"
|
||||
local target_path_b="$3"
|
||||
local label="$4"
|
||||
|
||||
if [[ ! -f "${source_path}" ]]; then
|
||||
echo "[deploy:rust] 跳过未找到的可选文件 ${label}: ${source_path}"
|
||||
return
|
||||
fi
|
||||
|
||||
cp "${source_path}" "${target_path_a}"
|
||||
cp "${source_path}" "${target_path_b}"
|
||||
normalize_env_file "${target_path_a}"
|
||||
normalize_env_file "${target_path_b}"
|
||||
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a} 与 ${target_path_b}"
|
||||
}
|
||||
|
||||
normalize_local_path_for_bash() {
|
||||
local value="$1"
|
||||
|
||||
if [[ "${value}" == "~"* ]]; then
|
||||
local rest="${value:1}"
|
||||
rest="${rest#\\}"
|
||||
rest="${rest#/}"
|
||||
rest="${rest//\\//}"
|
||||
printf "%s/%s" "${HOME}" "${rest}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${value}" =~ ^([A-Za-z]):\\(.*)$ ]]; then
|
||||
local drive
|
||||
drive="$(printf "%s" "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
|
||||
local rest="${BASH_REMATCH[2]//\\//}"
|
||||
printf "/%s/%s" "${drive}" "${rest}"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "%s" "${value}"
|
||||
}
|
||||
|
||||
remote_shell_quote() {
|
||||
local value="$1"
|
||||
printf "'%s'" "$(printf "%s" "${value}" | sed "s/'/'\\\\''/g")"
|
||||
}
|
||||
|
||||
replace_placeholder_in_file() {
|
||||
local file_path="$1"
|
||||
local placeholder="$2"
|
||||
local value="$3"
|
||||
local escaped_value="${value//\\/\\\\}"
|
||||
|
||||
escaped_value="${escaped_value//&/\\&}"
|
||||
escaped_value="${escaped_value//|/\\|}"
|
||||
sed -i "s|${placeholder}|${escaped_value}|g" "${file_path}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||
BUILD_ROOT="${REPO_ROOT}/build"
|
||||
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||||
DATABASE="xushi-p4wfr"
|
||||
API_HOST="127.0.0.1"
|
||||
API_PORT="8082"
|
||||
WEB_HOST="0.0.0.0"
|
||||
WEB_PORT="25001"
|
||||
SPACETIME_HOST="127.0.0.1"
|
||||
SPACETIME_PORT="3101"
|
||||
SSH_KEY='~\.ssh\dsk.pem'
|
||||
REMOTE_TARGET="ubuntu@82.157.175.59"
|
||||
REMOTE_DIR="/home/ubuntu/genarrative"
|
||||
UPLOAD_ENABLED=1
|
||||
SKIP_WEB_BUILD=0
|
||||
SKIP_API_BUILD=0
|
||||
SKIP_SPACETIME_BUILD=0
|
||||
BUILD_COMPLETED=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--name)
|
||||
BUILD_NAME="${2:?缺少 --name 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--database)
|
||||
DATABASE="${2:?缺少 --database 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--api-host)
|
||||
API_HOST="${2:?缺少 --api-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--api-port)
|
||||
API_PORT="${2:?缺少 --api-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--web-host)
|
||||
WEB_HOST="${2:?缺少 --web-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--web-port)
|
||||
WEB_PORT="${2:?缺少 --web-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-host)
|
||||
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-port)
|
||||
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--ssh-key)
|
||||
SSH_KEY="${2:?缺少 --ssh-key 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--remote)
|
||||
REMOTE_TARGET="${2:?缺少 --remote 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--remote-dir)
|
||||
REMOTE_DIR="${2:?缺少 --remote-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-upload)
|
||||
UPLOAD_ENABLED=0
|
||||
shift
|
||||
;;
|
||||
--skip-web-build)
|
||||
SKIP_WEB_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--skip-api-build)
|
||||
SKIP_API_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--skip-spacetime-build)
|
||||
SKIP_SPACETIME_BUILD=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[deploy:rust] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||||
echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||||
WEB_DIR="${TARGET_DIR}/web"
|
||||
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||||
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||||
|
||||
cleanup_partial_build() {
|
||||
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
|
||||
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
|
||||
rm -rf "${TARGET_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_partial_build EXIT
|
||||
|
||||
if [[ -e "${TARGET_DIR}" ]]; then
|
||||
echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_command node
|
||||
require_command cargo
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
require_command npm
|
||||
fi
|
||||
|
||||
NORMALIZED_SSH_KEY="$(normalize_local_path_for_bash "${SSH_KEY}")"
|
||||
|
||||
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||||
require_command ssh
|
||||
require_command scp
|
||||
|
||||
if [[ ! -f "${NORMALIZED_SSH_KEY}" ]]; then
|
||||
echo "[deploy:rust] SSH 私钥不存在: ${SSH_KEY}" >&2
|
||||
echo "[deploy:rust] Git Bash 解析路径: ${NORMALIZED_SSH_KEY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "${WEB_DIR}"
|
||||
|
||||
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||||
|
||||
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
|
||||
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
|
||||
echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu"
|
||||
(
|
||||
cd "${SERVER_RS_DIR}"
|
||||
cargo build \
|
||||
-p api-server \
|
||||
--release \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||||
)
|
||||
fi
|
||||
|
||||
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
|
||||
chmod +x "${TARGET_DIR}/api-server"
|
||||
|
||||
if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
|
||||
echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown"
|
||||
(
|
||||
cd "${SERVER_RS_DIR}"
|
||||
cargo build \
|
||||
-p spacetime-module \
|
||||
--release \
|
||||
--target wasm32-unknown-unknown \
|
||||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||||
)
|
||||
fi
|
||||
|
||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||
|
||||
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.join(releaseDir, 'web');
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
|
||||
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||
const indexPath = path.join(webRoot, 'index.html');
|
||||
const proxyPrefixes = [
|
||||
'/api/',
|
||||
'/api',
|
||||
'/generated-character-drafts',
|
||||
'/generated-characters',
|
||||
'/generated-animations',
|
||||
'/generated-custom-world-scenes',
|
||||
'/generated-custom-world-covers',
|
||||
'/generated-qwen-sprites',
|
||||
'/healthz',
|
||||
];
|
||||
|
||||
function isProxyPath(pathname) {
|
||||
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function contentTypeFor(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const typeMap = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
return typeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sendFile(response, filePath) {
|
||||
fs.createReadStream(filePath)
|
||||
.on('error', () => {
|
||||
response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'});
|
||||
response.end('failed to read static file');
|
||||
})
|
||||
.pipe(response);
|
||||
}
|
||||
|
||||
function serveStatic(request, response, pathname) {
|
||||
const decodedPath = decodeURIComponent(pathname);
|
||||
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
|
||||
const filePath = path.normalize(path.join(webRoot, relativePath));
|
||||
const safeRelativePath = path.relative(webRoot, filePath);
|
||||
|
||||
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
|
||||
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
|
||||
response.end('forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
? filePath
|
||||
: indexPath;
|
||||
|
||||
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
|
||||
sendFile(response, resolvedFilePath);
|
||||
}
|
||||
|
||||
function proxyToApi(request, response) {
|
||||
const targetUrl = new URL(request.url || '/', apiTarget);
|
||||
const proxyRequest = http.request(
|
||||
{
|
||||
hostname: targetUrl.hostname,
|
||||
method: request.method,
|
||||
path: `${targetUrl.pathname}${targetUrl.search}`,
|
||||
port: targetUrl.port || 80,
|
||||
protocol: targetUrl.protocol,
|
||||
headers: {
|
||||
...request.headers,
|
||||
host: apiTarget.host,
|
||||
},
|
||||
},
|
||||
(proxyResponse) => {
|
||||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||
proxyResponse.pipe(response);
|
||||
},
|
||||
);
|
||||
|
||||
proxyRequest.on('error', (error) => {
|
||||
response.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
|
||||
response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}}));
|
||||
});
|
||||
|
||||
request.pipe(proxyRequest);
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
|
||||
if (isProxyPath(url.pathname)) {
|
||||
proxyToApi(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(request, response, url.pathname);
|
||||
});
|
||||
|
||||
server.listen(webPort, webHost, () => {
|
||||
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
|
||||
});
|
||||
WEB_SERVER
|
||||
|
||||
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_DIR="${SCRIPT_DIR}/run"
|
||||
LOG_DIR="${SCRIPT_DIR}/logs"
|
||||
CLEAR_DATABASE=0
|
||||
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
local line=""
|
||||
local line_number=0
|
||||
local key=""
|
||||
local value=""
|
||||
local utf8_bom=$'\xef\xbb\xbf'
|
||||
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[start] 加载环境文件: ${env_file}"
|
||||
|
||||
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行。
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
if [[ "${line_number}" -eq 1 ]]; then
|
||||
line="${line#"${utf8_bom}"}"
|
||||
fi
|
||||
line="${line%$'\r'}"
|
||||
|
||||
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
key="${BASH_REMATCH[2]}"
|
||||
value="${BASH_REMATCH[3]}"
|
||||
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
value="${value//\\\"/\"}"
|
||||
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
fi
|
||||
|
||||
printf -v "${key}" '%s' "${value}"
|
||||
export "${key}"
|
||||
done <"${env_file}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./start.sh
|
||||
./start.sh --clear-database
|
||||
|
||||
说明:
|
||||
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
|
||||
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE,但不清库。
|
||||
3. 只有显式传入 --clear-database 时才会在 schema 冲突时清理旧模块数据后重发。
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--clear-database)
|
||||
CLEAR_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[start] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
load_env_file "${SCRIPT_DIR}/.env"
|
||||
load_env_file "${SCRIPT_DIR}/.env.local"
|
||||
|
||||
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
|
||||
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}"
|
||||
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}"
|
||||
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
|
||||
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}"
|
||||
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
|
||||
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
|
||||
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
|
||||
WEB_HOST="${GENARRATIVE_WEB_HOST:-__GENARRATIVE_DEFAULT_WEB_HOST__}"
|
||||
WEB_PORT="${GENARRATIVE_WEB_PORT:-__GENARRATIVE_DEFAULT_WEB_PORT__}"
|
||||
|
||||
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
|
||||
export NO_COLOR="${NO_COLOR:-1}"
|
||||
export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}"
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "[start] 缺少命令: ${command_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_spacetime() {
|
||||
local deadline=$((SECONDS + 60))
|
||||
|
||||
while ((SECONDS < deadline)); do
|
||||
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
start_process() {
|
||||
local name="$1"
|
||||
shift
|
||||
local pid_file="${PID_DIR}/${name}.pid"
|
||||
local log_file="${LOG_DIR}/${name}.log"
|
||||
|
||||
if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then
|
||||
echo "[start] ${name} 已在运行: $(cat "${pid_file}")"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[start] 启动 ${name}"
|
||||
nohup "$@" >"${log_file}" 2>&1 &
|
||||
echo "$!" >"${pid_file}"
|
||||
}
|
||||
|
||||
require_command node
|
||||
require_command spacetime
|
||||
|
||||
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
|
||||
|
||||
start_process spacetimedb \
|
||||
spacetime \
|
||||
start \
|
||||
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||
--non-interactive
|
||||
|
||||
wait_for_spacetime
|
||||
|
||||
PUBLISH_ARGS=(
|
||||
publish
|
||||
"${SPACETIME_DATABASE}"
|
||||
--server "${SPACETIME_SERVER_URL}"
|
||||
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
|
||||
--yes
|
||||
)
|
||||
|
||||
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||
# 发布包清库模式只在 schema 冲突时删除旧模块数据,避免无冲突升级误清数据。
|
||||
PUBLISH_ARGS+=(-c=on-conflict)
|
||||
fi
|
||||
|
||||
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||||
spacetime "${PUBLISH_ARGS[@]}"
|
||||
|
||||
export GENARRATIVE_API_HOST="${API_HOST}"
|
||||
export GENARRATIVE_API_PORT="${API_PORT}"
|
||||
export GENARRATIVE_API_LOG="${API_LOG}"
|
||||
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
|
||||
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
|
||||
start_process api-server "${SCRIPT_DIR}/api-server"
|
||||
|
||||
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
|
||||
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
|
||||
export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}"
|
||||
start_process web node "${SCRIPT_DIR}/web-server.mjs"
|
||||
|
||||
echo "[start] 完成"
|
||||
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
|
||||
echo "[start] API: http://${API_HOST}:${API_PORT}"
|
||||
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||||
START_SCRIPT
|
||||
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_HOST__" "${SPACETIME_HOST}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_PORT__" "${SPACETIME_PORT}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__" "${DATABASE}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_HOST__" "${API_HOST}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_PORT__" "${API_PORT}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_HOST__" "${WEB_HOST}"
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_PORT__" "${WEB_PORT}"
|
||||
|
||||
cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_DIR="${SCRIPT_DIR}/run"
|
||||
|
||||
stop_process() {
|
||||
local name="$1"
|
||||
local pid_file="${PID_DIR}/${name}.pid"
|
||||
|
||||
if [[ ! -f "${pid_file}" ]]; then
|
||||
echo "[stop] ${name} 未记录 pid"
|
||||
return
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "${pid_file}")"
|
||||
|
||||
if kill -0 "${pid}" 2>/dev/null; then
|
||||
echo "[stop] 停止 ${name} (pid=${pid})"
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
if kill -0 "${pid}" 2>/dev/null; then
|
||||
kill -9 "${pid}" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "[stop] ${name} 未运行"
|
||||
fi
|
||||
|
||||
rm -f "${pid_file}"
|
||||
}
|
||||
|
||||
stop_process web
|
||||
stop_process api-server
|
||||
stop_process spacetimedb
|
||||
|
||||
echo "[stop] 完成"
|
||||
STOP_SCRIPT
|
||||
|
||||
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
|
||||
|
||||
cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
# Genarrative Ubuntu Release
|
||||
|
||||
构建时间:\`${BUILD_NAME}\`
|
||||
|
||||
## 内容
|
||||
|
||||
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
|
||||
- \`web/\`:Vite release 静态资源
|
||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||
|
||||
## 启动
|
||||
|
||||
\`\`\`bash
|
||||
./start.sh
|
||||
\`\`\`
|
||||
|
||||
默认不清空 SpacetimeDB。如需开发库清库重发:
|
||||
|
||||
\`\`\`bash
|
||||
./start.sh --clear-database
|
||||
\`\`\`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||||
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
|
||||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
|
||||
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
||||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
|
||||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
|
||||
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
|
||||
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
|
||||
EOF
|
||||
|
||||
BUILD_COMPLETED=1
|
||||
|
||||
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||||
echo "[deploy:rust] 创建远端目录: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" "mkdir -p $(remote_shell_quote "${REMOTE_DIR}")"
|
||||
|
||||
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
fi
|
||||
|
||||
echo "[deploy:rust] 完成: ${TARGET_DIR}"
|
||||
451
scripts/dev-rust-stack.sh
Normal file
451
scripts/dev-rust-stack.sh
Normal file
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
npm run dev:rust
|
||||
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
|
||||
./scripts/dev-rust-stack.sh --api-timeout-seconds 600
|
||||
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
|
||||
./scripts/dev-rust-stack.sh --preserve-database
|
||||
npm run dev:rust:logs -- --follow
|
||||
|
||||
说明:
|
||||
1. 默认同时启动 SpacetimeDB standalone、Rust api-server 与 Vite 前端。
|
||||
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
|
||||
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
|
||||
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "[dev:rust] 缺少命令: ${command_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_client_host() {
|
||||
local host_name="$1"
|
||||
|
||||
if [[ "${host_name}" == "0.0.0.0" || "${host_name}" == "::" ]]; then
|
||||
echo "127.0.0.1"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "${host_name}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local index
|
||||
|
||||
for ((index = ${#PIDS[@]} - 1; index >= 0; index--)); do
|
||||
local pid="${PIDS[index]}"
|
||||
local name="${NAMES[index]}"
|
||||
|
||||
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] 停止 ${name} (pid=${pid})"
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
while read -r child_pid; do
|
||||
if [[ -n "${child_pid}" ]]; then
|
||||
kill "${child_pid}" 2>/dev/null || true
|
||||
fi
|
||||
done < <(pgrep -P "${pid}" 2>/dev/null || true)
|
||||
fi
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
sleep 0.2
|
||||
if kill -0 "${pid}" 2>/dev/null; then
|
||||
kill -9 "${pid}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_spacetime() {
|
||||
local server="$1"
|
||||
local timeout_seconds="$2"
|
||||
local root_dir="$3"
|
||||
local process_pid="${4:-}"
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if spacetime --root-dir="${root_dir}" server ping "${server}" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_spacetime_ready() {
|
||||
local server="$1"
|
||||
local root_dir="$2"
|
||||
|
||||
spacetime --root-dir="${root_dir}" server ping "${server}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
describe_spacetime_root_owner() {
|
||||
local root_dir="$1"
|
||||
local windows_root_dir="${root_dir}"
|
||||
|
||||
if [[ "${windows_root_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then
|
||||
windows_root_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
|
||||
fi
|
||||
|
||||
# Windows 本地开发最常见的失败是同一个 root-dir 下已有 standalone 持有 spacetime.pid;
|
||||
# 启动前先打印占用进程,避免用户只看到底层 os error 33 而不知道该停哪个实例。
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
ROOT_DIR_FOR_POWERSHELL="${windows_root_dir}" powershell.exe -NoProfile -Command '
|
||||
$rootDir = $env:ROOT_DIR_FOR_POWERSHELL
|
||||
$normalized = $rootDir.Replace("/", "\")
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$normalized*" } |
|
||||
ForEach-Object { "pid=$($_.ProcessId) name=$($_.Name) command=$($_.CommandLine)" }
|
||||
' 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v ps >/dev/null 2>&1; then
|
||||
ps -ef 2>/dev/null | grep '[s]pacetime' | grep -F "${root_dir}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_api_server() {
|
||||
local health_url="$1"
|
||||
local timeout_seconds="$2"
|
||||
local process_pid="${3:-}"
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 使用 Node 发起健康检查,避免要求 Windows 本地额外安装 curl/wget。
|
||||
if node -e '
|
||||
const target = process.argv[1];
|
||||
const client = target.startsWith("https:") ? require("https") : require("http");
|
||||
const request = client.get(target, { timeout: 1000 }, (response) => {
|
||||
response.resume();
|
||||
process.exit(response.statusCode >= 200 && response.statusCode < 500 ? 0 : 1);
|
||||
});
|
||||
request.on("timeout", () => request.destroy(new Error("timeout")));
|
||||
request.on("error", () => process.exit(1));
|
||||
' "${health_url}" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sync_local_spacetime_install() {
|
||||
local root_dir="$1"
|
||||
|
||||
# SpacetimeDB standalone 会在 --root-dir 下回调 bin/current/spacetimedb-cli.exe;
|
||||
# Windows 本地开发使用工程内 root-dir 时,需要把用户级安装目录同步进来。
|
||||
if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local target_cli="${root_dir}/bin/current/spacetimedb-cli.exe"
|
||||
if [[ -f "${target_cli}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local spacetime_command
|
||||
spacetime_command="$(command -v spacetime || true)"
|
||||
if [[ -z "${spacetime_command}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local install_dir
|
||||
install_dir="$(cd -- "$(dirname -- "${spacetime_command}")" && pwd)"
|
||||
if [[ ! -d "${install_dir}/bin" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[dev:rust] 同步本机 SpacetimeDB 安装到 ${root_dir}"
|
||||
mkdir -p "${root_dir}"
|
||||
cp -a "${install_dir}/bin" "${root_dir}/"
|
||||
if [[ -f "${install_dir}/spacetime.exe" ]]; then
|
||||
cp -f "${install_dir}/spacetime.exe" "${root_dir}/spacetime.exe"
|
||||
fi
|
||||
|
||||
# Git Bash 复制 Windows junction 时可能不会生成可执行的 current 目录;
|
||||
# 若 current 缺失,则用最新版本目录复制出一个真实目录,满足 standalone 回调路径。
|
||||
if [[ ! -f "${target_cli}" ]]; then
|
||||
local version_dir
|
||||
version_dir="$(find "${root_dir}/bin" -mindepth 1 -maxdepth 1 -type d ! -name current | sort -V | tail -n 1)"
|
||||
if [[ -n "${version_dir}" && -f "${version_dir}/spacetimedb-cli.exe" ]]; then
|
||||
rm -rf "${root_dir}/bin/current"
|
||||
cp -a "${version_dir}" "${root_dir}/bin/current"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||
MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml"
|
||||
MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module"
|
||||
VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs"
|
||||
|
||||
API_HOST="127.0.0.1"
|
||||
API_PORT="8082"
|
||||
WEB_HOST="0.0.0.0"
|
||||
WEB_PORT="3000"
|
||||
SPACETIME_HOST="127.0.0.1"
|
||||
SPACETIME_PORT="3101"
|
||||
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
|
||||
DATABASE=""
|
||||
API_LOG="info,tower_http=info"
|
||||
SPACETIME_TIMEOUT_SECONDS="60"
|
||||
API_SERVER_TIMEOUT_SECONDS="300"
|
||||
SKIP_SPACETIME=0
|
||||
SKIP_PUBLISH=0
|
||||
PRESERVE_DATABASE=0
|
||||
PIDS=()
|
||||
NAMES=()
|
||||
|
||||
read_local_spacetime_database() {
|
||||
local config_path="${REPO_ROOT}/spacetime.local.json"
|
||||
|
||||
if [[ ! -f "${config_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const path = process.argv[1];
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path, "utf8")).database;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
process.stdout.write(value.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`[dev:rust] ignore invalid spacetime.local.json: ${error.message}\n`);
|
||||
}
|
||||
' "${config_path}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--api-host)
|
||||
API_HOST="${2:?缺少 --api-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--api-port)
|
||||
API_PORT="${2:?缺少 --api-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--web-host)
|
||||
WEB_HOST="${2:?缺少 --web-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--web-port)
|
||||
WEB_PORT="${2:?缺少 --web-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-host)
|
||||
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-port)
|
||||
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-root-dir)
|
||||
SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--database)
|
||||
DATABASE="${2:?缺少 --database 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--log)
|
||||
API_LOG="${2:?缺少 --log 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-timeout-seconds)
|
||||
SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--api-timeout-seconds)
|
||||
API_SERVER_TIMEOUT_SECONDS="${2:?缺少 --api-timeout-seconds 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-spacetime)
|
||||
SKIP_SPACETIME=1
|
||||
shift
|
||||
;;
|
||||
--skip-publish)
|
||||
SKIP_PUBLISH=1
|
||||
shift
|
||||
;;
|
||||
--clear-database)
|
||||
PRESERVE_DATABASE=0
|
||||
shift
|
||||
;;
|
||||
--preserve-database)
|
||||
PRESERVE_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[dev:rust] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
|
||||
DATABASE="$(read_local_spacetime_database)"
|
||||
fi
|
||||
|
||||
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
|
||||
DATABASE="genarrative-dev"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${MANIFEST_PATH}" ]]; then
|
||||
echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${MODULE_PATH}/Cargo.toml" ]]; then
|
||||
echo "[dev:rust] 未找到 ${MODULE_PATH}/Cargo.toml,无法发布 SpacetimeDB 模块。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${VITE_CLI_PATH}" ]]; then
|
||||
echo "[dev:rust] 未找到 ${VITE_CLI_PATH},无法启动 Web 前端。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_command cargo
|
||||
require_command node
|
||||
|
||||
if [[ "${SKIP_SPACETIME}" -ne 1 || "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||
require_command spacetime
|
||||
fi
|
||||
|
||||
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
|
||||
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "[dev:rust] repo: ${REPO_ROOT}"
|
||||
echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}"
|
||||
echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
|
||||
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
|
||||
echo "[dev:rust] database: ${DATABASE}"
|
||||
echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}"
|
||||
echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
|
||||
|
||||
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
||||
mkdir -p "${SPACETIME_ROOT_DIR}"
|
||||
sync_local_spacetime_install "${SPACETIME_ROOT_DIR}"
|
||||
if is_spacetime_ready "${SPACETIME_SERVER}" "${SPACETIME_ROOT_DIR}"; then
|
||||
echo "[dev:rust] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER}"
|
||||
else
|
||||
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_ROOT_DIR}")"
|
||||
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
|
||||
echo "[dev:rust] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
|
||||
echo "[dev:rust] 目标地址未就绪: ${SPACETIME_SERVER}" >&2
|
||||
echo "[dev:rust] 如需复用,请传入占用实例实际端口,例如 --spacetime-port 3199;如需重启,请先停止下列进程。" >&2
|
||||
echo "${SPACETIME_ROOT_OWNER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[dev:rust] 启动 spacetimedb"
|
||||
(
|
||||
cd "${SERVER_RS_DIR}"
|
||||
exec spacetime \
|
||||
--root-dir="${SPACETIME_ROOT_DIR}" \
|
||||
start \
|
||||
--edition standalone \
|
||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||
) &
|
||||
PIDS+=("$!")
|
||||
NAMES+=("spacetimedb")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||
echo "[dev:rust] 等待 SpacetimeDB 就绪"
|
||||
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_ROOT_DIR}" "${PIDS[0]:-}"
|
||||
|
||||
PUBLISH_ARGS=(
|
||||
publish
|
||||
"${DATABASE}"
|
||||
--server "${SPACETIME_SERVER}"
|
||||
--module-path "${MODULE_PATH}"
|
||||
)
|
||||
|
||||
if [[ "${PRESERVE_DATABASE}" -ne 1 ]]; then
|
||||
PUBLISH_ARGS+=(-c=on-conflict)
|
||||
fi
|
||||
|
||||
PUBLISH_ARGS+=(--yes)
|
||||
|
||||
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
|
||||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"
|
||||
fi
|
||||
|
||||
echo "[dev:rust] 启动 api-server"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
GENARRATIVE_API_HOST="${API_HOST}" \
|
||||
GENARRATIVE_API_PORT="${API_PORT}" \
|
||||
GENARRATIVE_API_LOG="${API_LOG}" \
|
||||
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
|
||||
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
|
||||
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
|
||||
) &
|
||||
API_PID="$!"
|
||||
PIDS+=("${API_PID}")
|
||||
NAMES+=("api-server")
|
||||
|
||||
echo "[dev:rust] 等待 api-server 就绪"
|
||||
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}"
|
||||
|
||||
echo "[dev:rust] 启动 vite"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \
|
||||
GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \
|
||||
VITE_DEV_HOST="${WEB_HOST}" \
|
||||
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
|
||||
) &
|
||||
PIDS+=("$!")
|
||||
NAMES+=("vite")
|
||||
|
||||
echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。"
|
||||
|
||||
set +e
|
||||
wait -n "${PIDS[@]}"
|
||||
EXIT_CODE="$?"
|
||||
set -e
|
||||
|
||||
echo "[dev:rust] 子进程已退出,开始回收本地 Rust 栈,退出码: ${EXIT_CODE}"
|
||||
exit "${EXIT_CODE}"
|
||||
14
scripts/dev-server/README.md
Normal file
14
scripts/dev-server/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 已移除的旧 Vite 本地 API 链路
|
||||
|
||||
自 `2026-04-19` 起,`scripts/dev-server/**` 下的旧本地 API 实现代码已经从仓库删除。
|
||||
|
||||
当前正式开发入口统一为:
|
||||
|
||||
- `npm run dev`
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- `server-rs/crates/api-server/**`
|
||||
- `server-rs/crates/spacetime-module/**`
|
||||
|
||||
该目录只保留本说明文件,作为迁移结果标记。
|
||||
|
||||
不要在仓库中恢复或新增旧式 Vite `/api/*` 本地插件链路。
|
||||
39
scripts/dev-web-rust.mjs
Normal file
39
scripts/dev-web-rust.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
const mergedEnv = {
|
||||
...process.env,
|
||||
RUST_SERVER_TARGET:
|
||||
process.env.RUST_SERVER_TARGET ||
|
||||
process.env.GENARRATIVE_API_TARGET ||
|
||||
`http://127.0.0.1:${process.env.GENARRATIVE_API_PORT || '3100'}`,
|
||||
};
|
||||
|
||||
mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET =
|
||||
process.env.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET;
|
||||
|
||||
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);
|
||||
|
||||
const child = spawn(
|
||||
'node',
|
||||
['scripts/vite-cli.mjs', '--port=3000', '--host=0.0.0.0'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:web] 启动 Vite 失败: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[dev:web] Vite 被信号终止: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
191
scripts/jenkins-deploy-release.sh
Normal file
191
scripts/jenkins-deploy-release.sh
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative [--clear-database] [--hook-with-sudo]
|
||||
|
||||
说明:
|
||||
1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。
|
||||
2. 仅删除并替换发布产物文件,保留部署目录中的运行数据目录。
|
||||
3. 把指定发布目录中的内容覆盖到部署目录。
|
||||
4. 如指定 --clear-database,则以清库模式执行新版本 start.sh。
|
||||
5. 最后执行新版本 start.sh。
|
||||
|
||||
参数:
|
||||
--source-dir <path> 必填,待部署的发布目录,例如 build/123
|
||||
--deploy-dir <path> 必填,固定部署目录,例如 /var/lib/jenkins/deploy/Genarrative
|
||||
--clear-database 可选,启动新版本时追加 --clear-database
|
||||
--hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行
|
||||
EOF
|
||||
}
|
||||
|
||||
require_argument() {
|
||||
local value="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "[jenkins-deploy] 缺少参数: ${label}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_env_file() {
|
||||
local env_file="$1"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF,避免 start.sh 加载时报命令不存在。
|
||||
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
|
||||
mv "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
normalize_release_env_files() {
|
||||
local release_dir="$1"
|
||||
|
||||
normalize_env_file "${release_dir}/.env"
|
||||
normalize_env_file "${release_dir}/.env.local"
|
||||
normalize_env_file "${release_dir}/web/.env"
|
||||
normalize_env_file "${release_dir}/web/.env.local"
|
||||
}
|
||||
|
||||
SOURCE_DIR=""
|
||||
DEPLOY_DIR=""
|
||||
CLEAR_DATABASE="0"
|
||||
HOOK_WITH_SUDO="0"
|
||||
DEPLOY_ITEMS=(
|
||||
".env"
|
||||
".env.local"
|
||||
"README.md"
|
||||
"api-server"
|
||||
"spacetime_module.wasm"
|
||||
"start.sh"
|
||||
"stop.sh"
|
||||
"web"
|
||||
"web-server.mjs"
|
||||
)
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--source-dir)
|
||||
SOURCE_DIR="${2:?缺少 --source-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--deploy-dir)
|
||||
DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--clear-database)
|
||||
CLEAR_DATABASE="1"
|
||||
shift
|
||||
;;
|
||||
--hook-with-sudo)
|
||||
HOOK_WITH_SUDO="1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[jenkins-deploy] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_argument "${SOURCE_DIR}" "--source-dir"
|
||||
require_argument "${DEPLOY_DIR}" "--deploy-dir"
|
||||
|
||||
run_hook() {
|
||||
local hook_dir="$1"
|
||||
local hook_name="$2"
|
||||
shift 2
|
||||
local hook_path="${hook_dir}/${hook_name}"
|
||||
|
||||
if [[ ! -x "${hook_path}" ]]; then
|
||||
echo "[jenkins-deploy] hook 不存在或不可执行: ${hook_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 仅在启停脚本阶段使用 sudo,文件清理与移动仍保持普通权限,避免放大授权范围。
|
||||
if [[ "${HOOK_WITH_SUDO}" == "1" ]]; then
|
||||
echo "[jenkins-deploy] 使用 sudo 执行 ${hook_name}: ${hook_path}"
|
||||
(
|
||||
cd "${hook_dir}"
|
||||
sudo -n "${hook_path}" "$@"
|
||||
) || {
|
||||
echo "[jenkins-deploy] sudo 执行 ${hook_name} 失败,请确认 jenkins 用户已配置免密 sudo 权限。" >&2
|
||||
exit 1
|
||||
}
|
||||
return
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${hook_dir}"
|
||||
"./${hook_name}" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
if [[ ! -d "${SOURCE_DIR}" ]]; then
|
||||
echo "[jenkins-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
DEPLOY_DIR="$(cd "${DEPLOY_DIR}" && pwd)"
|
||||
|
||||
if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
|
||||
echo "[jenkins-deploy] 发布目录缺少 start.sh: ${SOURCE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_release_env_files "${SOURCE_DIR}"
|
||||
|
||||
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
|
||||
run_hook "${DEPLOY_DIR}" "stop.sh"
|
||||
else
|
||||
echo "[jenkins-deploy] 部署目录无可执行 stop.sh,跳过停服"
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}"
|
||||
for item in "${DEPLOY_ITEMS[@]}"; do
|
||||
if [[ -e "${DEPLOY_DIR}/${item}" ]]; then
|
||||
echo "[jenkins-deploy] 删除旧产物: ${DEPLOY_DIR}/${item}"
|
||||
rm -rf "${DEPLOY_DIR:?}/${item}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[jenkins-deploy] 移动发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
|
||||
for item in "${DEPLOY_ITEMS[@]}"; do
|
||||
if [[ -e "${SOURCE_DIR}/${item}" ]]; then
|
||||
echo "[jenkins-deploy] 覆盖产物: ${item}"
|
||||
mv "${SOURCE_DIR}/${item}" "${DEPLOY_DIR}/"
|
||||
fi
|
||||
done
|
||||
|
||||
chmod +x "${DEPLOY_DIR}/start.sh"
|
||||
|
||||
if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
chmod +x "${DEPLOY_DIR}/stop.sh"
|
||||
fi
|
||||
|
||||
normalize_release_env_files "${DEPLOY_DIR}"
|
||||
|
||||
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
|
||||
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
|
||||
echo "[jenkins-deploy] 以清库模式启动新版本"
|
||||
run_hook "${DEPLOY_DIR}" "start.sh" --clear-database
|
||||
else
|
||||
run_hook "${DEPLOY_DIR}" "start.sh"
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 完成"
|
||||
55
scripts/run-bash-script.mjs
Normal file
55
scripts/run-bash-script.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import {existsSync} from 'node:fs';
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
const [, , scriptPath, ...scriptArgs] = process.argv;
|
||||
|
||||
if (!scriptPath) {
|
||||
console.error('[run-bash-script] missing script path.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolveBashCommand() {
|
||||
if (process.env.GENARRATIVE_BASH) {
|
||||
return process.env.GENARRATIVE_BASH;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
||||
'C:\\msys64\\usr\\bin\\bash.exe',
|
||||
];
|
||||
|
||||
const matched = candidates.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
const bashCommand = resolveBashCommand();
|
||||
const child = spawn(bashCommand, [scriptPath, ...scriptArgs], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[run-bash-script] failed to start bash: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[run-bash-script] bash exited by signal: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
15
scripts/run-tsx.cjs
Normal file
15
scripts/run-tsx.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
const path = require('node:path');
|
||||
|
||||
const {require: tsxRequire} = require('tsx/cjs/api');
|
||||
|
||||
const [, , entry, ...restArgs] = process.argv;
|
||||
|
||||
if (!entry) {
|
||||
console.error('Usage: node scripts/run-tsx.cjs <entry.ts> [...args]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolvedEntry = path.resolve(process.cwd(), entry);
|
||||
process.argv = [process.argv[0], resolvedEntry, ...restArgs];
|
||||
|
||||
tsxRequire(resolvedEntry, path.join(process.cwd(), '__tsx_runner__.cjs'));
|
||||
430
scripts/smoke-content.ts
Normal file
430
scripts/smoke-content.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { buildCompanionState, ROLE_TEMPLATE_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts';
|
||||
import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts';
|
||||
import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts';
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
buildEncounterTransitionState,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../src/data/encounterTransition.ts';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
getEquipmentBonuses,
|
||||
} from '../src/data/equipmentEffects.ts';
|
||||
import { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts';
|
||||
import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts';
|
||||
import {
|
||||
acceptQuest,
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromNpcTalk,
|
||||
buildQuestForEncounter,
|
||||
findQuestById,
|
||||
markQuestTurnedIn,
|
||||
} from '../src/data/questFlow.ts';
|
||||
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
|
||||
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts';
|
||||
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { resolveFunctionOption } from '../src/data/stateFunctions.ts';
|
||||
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
|
||||
import { AnimationState, GameState, WorldType } from '../src/types.ts';
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createBaseState(worldType: WorldType, sceneId?: string): GameState {
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const currentScenePreset = sceneId
|
||||
? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null
|
||||
: getScenePresetsByWorld(worldType)[0] ?? null;
|
||||
|
||||
return {
|
||||
worldType,
|
||||
customWorldProfile: null,
|
||||
playerCharacter,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
ambientIdleMode: undefined,
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 180,
|
||||
playerMaxHp: 180,
|
||||
playerMana: 100,
|
||||
playerMaxMana: 100,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 180,
|
||||
playerInventory: [],
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function smokeScenePreviews() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scene = getScenePresetsByWorld(worldType)[0];
|
||||
assert(scene, `[preview] missing first scene for ${worldType}`);
|
||||
|
||||
const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id));
|
||||
assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`);
|
||||
assert(preview.currentEncounter || preview.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
|
||||
|
||||
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
|
||||
assert(ensured.currentEncounter || ensured.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeNpcStories() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0);
|
||||
assert(sceneWithNpc, `[npc] missing npc scene for ${worldType}`);
|
||||
|
||||
const encounter = {
|
||||
id: sceneWithNpc.npcs[0].id,
|
||||
kind: 'npc' as const,
|
||||
characterId: sceneWithNpc.npcs[0].characterId,
|
||||
npcName: sceneWithNpc.npcs[0].name,
|
||||
npcDescription: sceneWithNpc.npcs[0].description,
|
||||
npcAvatar: sceneWithNpc.npcs[0].avatar,
|
||||
context: sceneWithNpc.npcs[0].role,
|
||||
xMeters: 3.2,
|
||||
};
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const npcState = buildInitialNpcState(encounter, worldType);
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState,
|
||||
playerCharacter,
|
||||
playerInventory: [],
|
||||
activeQuests: [],
|
||||
scene: sceneWithNpc,
|
||||
worldType,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
assert(story.options.length >= 3, `[npc] ${sceneWithNpc.id} npc story returned too few options`);
|
||||
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar');
|
||||
assert(battleMonster.hp >= 7 && battleMonster.hp <= 12, `[npc] spar hp for ${encounter.npcName} out of expected range`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeTreasureStories() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithTreasure = getScenePresetsByWorld(worldType).find(scene => scene.treasureHints.length > 0);
|
||||
assert(sceneWithTreasure, `[treasure] missing treasure scene for ${worldType}`);
|
||||
const state = createBaseState(worldType, sceneWithTreasure.id);
|
||||
|
||||
const encounter = {
|
||||
id: `treasure-${sceneWithTreasure.id}`,
|
||||
kind: 'treasure' as const,
|
||||
npcName: '前方宝藏',
|
||||
npcDescription: `你在前方发现了${sceneWithTreasure.treasureHints[0]}的痕迹。`,
|
||||
npcAvatar: '/Icons/47_treasure.png',
|
||||
context: '宝藏',
|
||||
xMeters: 3.2,
|
||||
};
|
||||
|
||||
const story = buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
});
|
||||
|
||||
assert(story.options.length === 3, `[treasure] ${sceneWithTreasure.id} treasure story should provide exactly 3 options`);
|
||||
const inspectReward = resolveTreasureReward(state, encounter, 'inspect');
|
||||
assert(inspectReward.items.length >= 2, `[treasure] ${sceneWithTreasure.id} inspect reward should contain at least 2 items`);
|
||||
assert(buildTreasureResultText(encounter, 'inspect', inspectReward).includes('收'), `[treasure] ${sceneWithTreasure.id} inspect result text should describe loot`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeMonsterCreation() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length > 0);
|
||||
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
|
||||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
|
||||
const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
|
||||
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
|
||||
assert(
|
||||
monsters.length === Math.min(hostileNpcPresetIds.length, 3),
|
||||
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
|
||||
);
|
||||
|
||||
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
|
||||
resolvedState.sceneHostileNpcs = monsters;
|
||||
resolvedState.inBattle = true;
|
||||
assert(
|
||||
resolvedState.sceneHostileNpcs.length === monsters.length,
|
||||
`[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeRecruitmentData() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithCharacterNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.some(npc => npc.characterId));
|
||||
assert(sceneWithCharacterNpc, `[recruit] missing recruitable character npc scene for ${worldType}`);
|
||||
const recruitableNpc = sceneWithCharacterNpc.npcs.find(npc => npc.characterId)!;
|
||||
const recruitCharacter = resolveEncounterRecruitCharacter({
|
||||
characterId: recruitableNpc.characterId,
|
||||
context: recruitableNpc.role,
|
||||
npcName: recruitableNpc.name,
|
||||
});
|
||||
assert(recruitCharacter, `[recruit] failed to resolve recruit character for ${recruitableNpc.id}`);
|
||||
const companionState = buildCompanionState(recruitableNpc.id, recruitCharacter, 60);
|
||||
assert(companionState.hp > 0 && companionState.maxHp >= companionState.hp, `[recruit] invalid hp for ${recruitableNpc.id}`);
|
||||
assert(Object.keys(companionState.skillCooldowns).length === recruitCharacter.skills.length, `[recruit] cooldown map mismatch for ${recruitableNpc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeObserveAndCallOut() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scene = getScenePresetsByWorld(worldType)[0];
|
||||
assert(scene, `[idle] missing first scene for ${worldType}`);
|
||||
const baseState = createBaseState(worldType, scene.id);
|
||||
const callOutResult = createSceneCallOutEncounter(baseState);
|
||||
assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`);
|
||||
assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
|
||||
|
||||
const observeOption = resolveFunctionOption(
|
||||
'idle_observe_signs',
|
||||
{
|
||||
worldType,
|
||||
playerCharacter: baseState.playerCharacter,
|
||||
inBattle: false,
|
||||
currentSceneId: scene.id,
|
||||
currentSceneName: scene.name,
|
||||
monsters: [],
|
||||
playerHp: baseState.playerHp,
|
||||
playerMaxHp: baseState.playerMaxHp,
|
||||
playerMana: baseState.playerMana,
|
||||
playerMaxMana: baseState.playerMaxMana,
|
||||
},
|
||||
'观察周围动静',
|
||||
);
|
||||
assert(observeOption?.functionId === 'idle_observe_signs', `[idle] observe_signs option missing for ${scene.id}`);
|
||||
assert(Boolean(observeOption?.detailText?.trim()), `[idle] observe_signs detail missing for ${scene.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeInventoryUseLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const inventory = buildInitialPlayerInventory(playerCharacter, worldType);
|
||||
const usableItem = inventory.find(item => isInventoryItemUsable(item));
|
||||
assert(usableItem, `[inventory] missing usable starter item for ${worldType}`);
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(usableItem, playerCharacter);
|
||||
assert(effect, `[inventory] failed to resolve use effect for ${usableItem.name}`);
|
||||
assert(
|
||||
effect.hpRestore > 0 || effect.manaRestore > 0 || effect.cooldownReduction > 0,
|
||||
`[inventory] ${usableItem.name} should provide at least one useful effect`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeEquipmentLoop() {
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||
const starterBonuses = getEquipmentBonuses(starterLoadout);
|
||||
|
||||
assert(starterBonuses.maxHpBonus > 0, '[equipment] starter loadout should provide HP bonus');
|
||||
assert(starterBonuses.outgoingDamageMultiplier > 1, '[equipment] starter loadout should provide damage bonus');
|
||||
|
||||
const baseState = createBaseState(WorldType.WUXIA);
|
||||
const equippedState = applyEquipmentLoadoutToState(baseState, starterLoadout);
|
||||
assert(equippedState.playerMaxHp > baseState.playerMaxHp, '[equipment] applying loadout should increase max HP');
|
||||
assert(equippedState.playerMaxMana > baseState.playerMaxMana, '[equipment] applying loadout should increase max mana');
|
||||
}
|
||||
|
||||
function smokeTradeEconomyLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0);
|
||||
assert(sceneWithNpc, `[trade] missing npc scene for ${worldType}`);
|
||||
|
||||
const encounter = {
|
||||
id: sceneWithNpc.npcs[0].id,
|
||||
kind: 'npc' as const,
|
||||
characterId: sceneWithNpc.npcs[0].characterId,
|
||||
npcName: sceneWithNpc.npcs[0].name,
|
||||
npcDescription: sceneWithNpc.npcs[0].description,
|
||||
npcAvatar: sceneWithNpc.npcs[0].avatar,
|
||||
context: sceneWithNpc.npcs[0].role,
|
||||
xMeters: 3.2,
|
||||
};
|
||||
const npcState = buildInitialNpcState(encounter, worldType);
|
||||
const npcItem = npcState.inventory[0];
|
||||
const playerItem = buildInitialPlayerInventory(ROLE_TEMPLATE_CHARACTERS[0], worldType)[0];
|
||||
assert(npcItem, `[trade] missing npc item for ${worldType}`);
|
||||
assert(playerItem, `[trade] missing player item for ${worldType}`);
|
||||
|
||||
const npcItemValue = getInventoryItemValue(npcItem);
|
||||
const playerItemValue = getInventoryItemValue(playerItem);
|
||||
assert(npcItemValue > 0 && playerItemValue > 0, `[trade] item values should be positive for ${worldType}`);
|
||||
|
||||
const purchasePrice = getNpcPurchasePrice(npcItem, npcState.affinity);
|
||||
assert(purchasePrice > 0, `[trade] purchase price should be positive for ${worldType}`);
|
||||
|
||||
const purchaseCheck = checkTradeItem(null, npcItem, npcState.affinity, purchasePrice);
|
||||
assert(purchaseCheck.canPurchase, `[trade] direct purchase should succeed when currency matches price for ${worldType}`);
|
||||
|
||||
const barterCheck = checkTradeItem(playerItem, npcItem, npcState.affinity, 0);
|
||||
assert(typeof barterCheck.canBarter === 'boolean', `[trade] barter check should return a boolean for ${worldType}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeEncounterTransitionLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length >= 2);
|
||||
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
|
||||
|
||||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
|
||||
const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
|
||||
const finalState = {
|
||||
...createBaseState(worldType, sceneWithMonster.id),
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: finalMonsters,
|
||||
};
|
||||
const previewState = {
|
||||
...finalState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: finalMonsters.map((monster, index) => ({
|
||||
...monster,
|
||||
xMeters: 12 + (index * 1.8),
|
||||
})),
|
||||
};
|
||||
|
||||
const transitionState = buildEncounterTransitionState(finalState, previewState);
|
||||
assert(
|
||||
transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters,
|
||||
`[transition] second monster should keep its preview x during transition for ${worldType}`,
|
||||
);
|
||||
|
||||
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
|
||||
assert(
|
||||
halfwayState.sceneHostileNpcs.every((monster, index) => {
|
||||
const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
|
||||
const endX = finalState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
|
||||
return monster.xMeters !== startX && monster.xMeters !== endX;
|
||||
}),
|
||||
`[transition] all monsters should interpolate instead of only the first one for ${worldType}`,
|
||||
);
|
||||
|
||||
const offscreenState = buildEncounterEntryState(finalState, 18);
|
||||
assert(
|
||||
offscreenState.sceneHostileNpcs.every(monster => monster.xMeters >= 18),
|
||||
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeRosterLoop() {
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1];
|
||||
const recruitCharacter = ROLE_TEMPLATE_CHARACTERS[2];
|
||||
const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68);
|
||||
const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62);
|
||||
const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72);
|
||||
|
||||
const baseState = {
|
||||
...createBaseState(WorldType.WUXIA),
|
||||
companions: [activeCompanion],
|
||||
roster: [reserveCompanion],
|
||||
};
|
||||
|
||||
const benchedState = benchActiveCompanion(baseState, activeCompanion.npcId);
|
||||
assert(benchedState.companions.length === 0, '[roster] active companion should move off active team');
|
||||
assert(benchedState.roster.some(companion => companion.npcId === activeCompanion.npcId), '[roster] benched companion should enter reserve roster');
|
||||
|
||||
const activatedState = activateRosterCompanion(baseState, reserveCompanion.npcId);
|
||||
assert(activatedState.companions.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] reserve companion should be activatable');
|
||||
assert(!activatedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] activated companion should leave reserve roster');
|
||||
|
||||
const swappedState = recruitCompanionToParty(
|
||||
{
|
||||
...baseState,
|
||||
companions: [activeCompanion, reserveCompanion],
|
||||
roster: [],
|
||||
},
|
||||
recruitedCompanion,
|
||||
reserveCompanion.npcId,
|
||||
);
|
||||
assert(swappedState.companions.some(companion => companion.npcId === recruitedCompanion.npcId), '[roster] recruited companion should join active party');
|
||||
assert(swappedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] replaced companion should move to reserve roster');
|
||||
}
|
||||
|
||||
function smokeQuestLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find(
|
||||
scene => scene.npcs.length > 0 && getSceneHostileNpcPresetIds(scene).length > 0,
|
||||
);
|
||||
assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`);
|
||||
|
||||
const issuer = sceneWithNpcAndMonster.npcs[0];
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: issuer.id,
|
||||
issuerNpcName: issuer.name,
|
||||
roleText: issuer.role,
|
||||
scene: sceneWithNpcAndMonster,
|
||||
worldType,
|
||||
});
|
||||
|
||||
assert(quest, `[quest] failed to build quest for ${sceneWithNpcAndMonster.id}`);
|
||||
const accepted = acceptQuest([], quest);
|
||||
assert(findQuestById(accepted, quest.id)?.status === 'active', `[quest] ${quest.id} should be active after accept`);
|
||||
|
||||
const afterBattle = applyQuestProgressFromHostileNpcDefeat(
|
||||
accepted,
|
||||
sceneWithNpcAndMonster.id,
|
||||
quest.objective.targetHostileNpcId ? [quest.objective.targetHostileNpcId] : [],
|
||||
);
|
||||
assert(findQuestById(afterBattle, quest.id)?.status === 'active', `[quest] ${quest.id} should stay active until report back`);
|
||||
|
||||
const afterReport = applyQuestProgressFromNpcTalk(afterBattle, issuer.id);
|
||||
assert(findQuestById(afterReport, quest.id)?.status === 'ready_to_turn_in', `[quest] ${quest.id} should become reward-ready after reporting back`);
|
||||
|
||||
const turnedIn = markQuestTurnedIn(afterReport, quest.id);
|
||||
assert(findQuestById(turnedIn, quest.id)?.status === 'turned_in', `[quest] ${quest.id} should turn in successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
smokeScenePreviews();
|
||||
smokeNpcStories();
|
||||
smokeTreasureStories();
|
||||
smokeMonsterCreation();
|
||||
smokeRecruitmentData();
|
||||
smokeObserveAndCallOut();
|
||||
smokeInventoryUseLoop();
|
||||
smokeEquipmentLoop();
|
||||
smokeTradeEconomyLoop();
|
||||
smokeEncounterTransitionLoop();
|
||||
smokeRosterLoop();
|
||||
smokeQuestLoop();
|
||||
console.log('Content smoke checks passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
129
scripts/spacetime-logs-local.sh
Normal file
129
scripts/spacetime-logs-local.sh
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
npm run dev:rust:logs
|
||||
npm run dev:rust:logs -- --follow
|
||||
./scripts/spacetime-logs-local.sh --lines 500 --output logs/spacetime/local.log
|
||||
|
||||
说明:
|
||||
1. 从本地 SpacetimeDB 通过 spacetime logs 提取模块日志到本地文件。
|
||||
2. 默认读取 spacetime.local.json 的 database 字段,默认 server 为 http://127.0.0.1:3101。
|
||||
3. 默认输出到 logs/spacetime/<database>-<timestamp>.log;--follow 会持续追加并同步写到终端。
|
||||
4. 默认使用 server-rs/.spacetimedb/local 作为 spacetime CLI root,保持本地身份与 standalone 一致。
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "[stdb:logs] 缺少命令: ${command_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
read_local_spacetime_database() {
|
||||
local config_path="${REPO_ROOT}/spacetime.local.json"
|
||||
|
||||
if [[ ! -f "${config_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const path = process.argv[1];
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path, "utf8")).database;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
process.stdout.write(value.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`[stdb:logs] ignore invalid spacetime.local.json: ${error.message}\n`);
|
||||
}
|
||||
' "${config_path}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
DATABASE=""
|
||||
SPACETIME_SERVER="http://127.0.0.1:3101"
|
||||
SPACETIME_ROOT_DIR="${REPO_ROOT}/server-rs/.spacetimedb/local"
|
||||
LINES="200"
|
||||
OUTPUT=""
|
||||
FOLLOW=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--database)
|
||||
DATABASE="${2:?缺少 --database 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--server)
|
||||
SPACETIME_SERVER="${2:?缺少 --server 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--spacetime-root-dir)
|
||||
SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--lines|-n)
|
||||
LINES="${2:?缺少 --lines 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--output|-o)
|
||||
OUTPUT="${2:?缺少 --output 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--follow|-f)
|
||||
FOLLOW=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[stdb:logs] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_command node
|
||||
require_command spacetime
|
||||
|
||||
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
|
||||
DATABASE="$(read_local_spacetime_database)"
|
||||
fi
|
||||
|
||||
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
|
||||
DATABASE="genarrative-dev"
|
||||
fi
|
||||
|
||||
if [[ -z "${OUTPUT//[[:space:]]/}" ]]; then
|
||||
LOG_DIR="${REPO_ROOT}/logs/spacetime"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT="${LOG_DIR}/${DATABASE}-${TIMESTAMP}.log"
|
||||
else
|
||||
mkdir -p "$(dirname -- "${OUTPUT}")"
|
||||
fi
|
||||
|
||||
ARGS=(logs "${DATABASE}" --server "${SPACETIME_SERVER}" -n "${LINES}")
|
||||
|
||||
if [[ "${FOLLOW}" -eq 1 ]]; then
|
||||
ARGS+=(-f)
|
||||
fi
|
||||
|
||||
echo "[stdb:logs] database: ${DATABASE}"
|
||||
echo "[stdb:logs] server: ${SPACETIME_SERVER}"
|
||||
echo "[stdb:logs] spacetime root: ${SPACETIME_ROOT_DIR}"
|
||||
echo "[stdb:logs] output: ${OUTPUT}"
|
||||
|
||||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${ARGS[@]}" | tee -a "${OUTPUT}"
|
||||
125
scripts/spacetime-publish-maincloud.sh
Normal file
125
scripts/spacetime-publish-maincloud.sh
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||
MODULE_PATH="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||||
SPACETIME_SERVER_ALIAS="maincloud"
|
||||
CLEAR_DATABASE=0
|
||||
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
local line key value
|
||||
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
line="${line#$'\xef\xbb\xbf'}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
[[ "${line}" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
|
||||
key="${BASH_REMATCH[1]}"
|
||||
value="${BASH_REMATCH[2]}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
if [[ -z "${!key+x}" ]]; then
|
||||
export "${key}=${value}"
|
||||
fi
|
||||
done <"${env_file}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
npm run spacetime:publish:maincloud
|
||||
npm run spacetime:publish:maincloud -- --database <database>
|
||||
npm run spacetime:publish:maincloud -- --clear-database
|
||||
|
||||
说明:
|
||||
发布 server-rs/crates/spacetime-module 到 SpacetimeDB Maincloud。
|
||||
数据库名优先读取 --database,其次读取 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。
|
||||
EOF
|
||||
}
|
||||
|
||||
load_env_file "${REPO_ROOT}/.env"
|
||||
load_env_file "${REPO_ROOT}/.env.local"
|
||||
|
||||
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE:-}"
|
||||
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL:-https://maincloud.spacetimedb.com}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--database)
|
||||
SPACETIME_DATABASE="${2:?缺少 --database 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--server-url)
|
||||
SPACETIME_SERVER_URL="${2:?缺少 --server-url 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--clear-database)
|
||||
CLEAR_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[spacetime:maincloud] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${SPACETIME_DATABASE}" ]]; then
|
||||
echo "[spacetime:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。" >&2
|
||||
echo "[spacetime:maincloud] 请在 .env.local 中配置,或通过 --database <database> 传入。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 cargo 命令。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v spacetime >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 spacetime CLI,请先安装并登录 Maincloud。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[spacetime:maincloud] 构建 spacetime-module wasm"
|
||||
cargo build \
|
||||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml" \
|
||||
-p spacetime-module \
|
||||
--target wasm32-unknown-unknown \
|
||||
--release
|
||||
|
||||
PUBLISH_ARGS=(
|
||||
publish
|
||||
"${SPACETIME_DATABASE}"
|
||||
--server "${SPACETIME_SERVER_ALIAS}"
|
||||
--bin-path "${MODULE_PATH}"
|
||||
--yes
|
||||
)
|
||||
|
||||
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||
# Maincloud 清库只在 schema 冲突时触发,避免无冲突升级误删线上数据。
|
||||
PUBLISH_ARGS+=(-c=on-conflict)
|
||||
fi
|
||||
|
||||
echo "[spacetime:maincloud] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE} -> ${SPACETIME_SERVER_ALIAS}"
|
||||
spacetime "${PUBLISH_ARGS[@]}"
|
||||
|
||||
cat <<EOF
|
||||
[spacetime:maincloud] 发布完成。api-server 可使用以下环境:
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=${SPACETIME_SERVER_URL}
|
||||
GENARRATIVE_SPACETIME_DATABASE=${SPACETIME_DATABASE}
|
||||
GENARRATIVE_SPACETIME_TOKEN=
|
||||
EOF
|
||||
116
scripts/validate-content.ts
Normal file
116
scripts/validate-content.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||
import { WorldType } from '../src/types.ts';
|
||||
|
||||
function addError(errors: string[], message: string) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function validateScenes(errors: string[]) {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scenes = getScenePresetsByWorld(worldType);
|
||||
const sceneIdSet = new Set(scenes.map(scene => scene.id));
|
||||
const monsterIdSet = new Set(MONSTER_PRESETS_BY_WORLD[worldType].map(monster => monster.id));
|
||||
const duplicateSceneIds = scenes
|
||||
.map(scene => scene.id)
|
||||
.filter((id, index, all) => all.indexOf(id) !== index);
|
||||
|
||||
duplicateSceneIds.forEach(sceneId => {
|
||||
addError(errors, `[scene] duplicate id "${sceneId}" in ${worldType}`);
|
||||
});
|
||||
|
||||
scenes.forEach(scene => {
|
||||
if (scene.forwardSceneId && !sceneIdSet.has(scene.forwardSceneId)) {
|
||||
addError(errors, `[scene] ${scene.id} forwardSceneId "${scene.forwardSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
|
||||
scene.connectedSceneIds.forEach(connectedSceneId => {
|
||||
if (!sceneIdSet.has(connectedSceneId)) {
|
||||
addError(errors, `[scene] ${scene.id} connectedSceneId "${connectedSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
});
|
||||
|
||||
getSceneHostileNpcPresetIds(scene).forEach(monsterId => {
|
||||
if (!monsterIdSet.has(monsterId)) {
|
||||
addError(errors, `[scene] ${scene.id} references unknown monster "${monsterId}" in ${worldType}`);
|
||||
}
|
||||
});
|
||||
|
||||
const npcIds = new Set<string>();
|
||||
scene.npcs.forEach(npc => {
|
||||
if (npcIds.has(npc.id)) {
|
||||
addError(errors, `[scene] ${scene.id} has duplicate npc id "${npc.id}"`);
|
||||
}
|
||||
npcIds.add(npc.id);
|
||||
|
||||
if (npc.characterId && !ROLE_TEMPLATE_CHARACTERS.some(character => character.id === npc.characterId)) {
|
||||
addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateCharacters(errors: string[]) {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id));
|
||||
|
||||
ROLE_TEMPLATE_CHARACTERS.forEach(character => {
|
||||
const homeSceneId = getCharacterHomeSceneId(worldType, character.id);
|
||||
if (homeSceneId && !sceneIdSet.has(homeSceneId)) {
|
||||
addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
|
||||
getCharacterNpcSceneIds(worldType, character.id).forEach(sceneId => {
|
||||
if (!sceneIdSet.has(sceneId)) {
|
||||
addError(errors, `[character] ${character.id} npc scene "${sceneId}" not found in ${worldType}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateStateFunctions(errors: string[]) {
|
||||
const definitions = buildStateFunctionDefinitions();
|
||||
const duplicateIds = definitions
|
||||
.map(definition => definition.id)
|
||||
.filter((id, index, all) => all.indexOf(id) !== index);
|
||||
|
||||
duplicateIds.forEach(id => {
|
||||
addError(errors, `[function] duplicate function id "${id}"`);
|
||||
});
|
||||
|
||||
definitions.forEach(definition => {
|
||||
if (!definition.text.trim()) {
|
||||
addError(errors, `[function] ${definition.id} has empty text`);
|
||||
}
|
||||
if (!definition.description.trim()) {
|
||||
addError(errors, `[function] ${definition.id} has empty description`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors: string[] = [];
|
||||
|
||||
validateScenes(errors);
|
||||
validateCharacters(errors);
|
||||
validateStateFunctions(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Content validation failed with ${errors.length} issue(s):`);
|
||||
errors.forEach(error => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneCount = getScenePresetsByWorld(WorldType.WUXIA).length + getScenePresetsByWorld(WorldType.XIANXIA).length;
|
||||
const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length;
|
||||
const functionCount = buildStateFunctionDefinitions().length;
|
||||
|
||||
console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${ROLE_TEMPLATE_CHARACTERS.length} functions=${functionCount}`);
|
||||
}
|
||||
|
||||
main();
|
||||
277
scripts/validate-overrides.ts
Normal file
277
scripts/validate-overrides.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { buildItemCatalogId } from '../src/data/itemCatalog.ts';
|
||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||
import { WorldType } from '../src/types.ts';
|
||||
|
||||
function readJsonFile<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(readFileSync(absolutePath, 'utf8')) as T;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function expectPlainObject(errors: string[], label: string, value: unknown) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`[override] ${label} must be an object map`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCharacterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/characterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'characterOverrides', overrides)) return;
|
||||
|
||||
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([characterId, override]) => {
|
||||
if (!characterIds.has(characterId)) {
|
||||
errors.push(`[override] characterOverrides contains unknown character id "${characterId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const sceneBindings = override.sceneBindings;
|
||||
if (sceneBindings !== undefined) {
|
||||
if (!isPlainObject(sceneBindings)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings must be an object`);
|
||||
} else {
|
||||
Object.entries(sceneBindings).forEach(([worldKey, binding]) => {
|
||||
if (!isPlainObject(binding)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings["${worldKey}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const homeSceneId = binding.homeSceneId;
|
||||
if (homeSceneId !== undefined && (typeof homeSceneId !== 'string' || !sceneIds.has(homeSceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid homeSceneId "${String(homeSceneId)}"`);
|
||||
}
|
||||
|
||||
const npcSceneIds = binding.npcSceneIds;
|
||||
if (npcSceneIds !== undefined) {
|
||||
if (!Array.isArray(npcSceneIds) || npcSceneIds.some(sceneId => typeof sceneId !== 'string' || !sceneIds.has(sceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid npcSceneIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateMonsterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/monsterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return;
|
||||
|
||||
const hostilePresetIds = new Set(
|
||||
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([monsterId, override]) => {
|
||||
if (!hostilePresetIds.has(monsterId)) {
|
||||
errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] monsterOverrides["${monsterId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneOverrides', overrides)) return;
|
||||
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
Object.entries(overrides).forEach(([sceneId, override]) => {
|
||||
if (!sceneIds.has(sceneId)) {
|
||||
errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardSceneId = override.forwardSceneId;
|
||||
if (forwardSceneId !== undefined && (typeof forwardSceneId !== 'string' || !sceneIds.has(forwardSceneId))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid forwardSceneId "${String(forwardSceneId)}"`);
|
||||
}
|
||||
|
||||
const connectedSceneIds = override.connectedSceneIds;
|
||||
if (connectedSceneIds !== undefined) {
|
||||
if (!Array.isArray(connectedSceneIds) || connectedSceneIds.some(id => typeof id !== 'string' || !sceneIds.has(id))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneNpcOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneNpcOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneNpcOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] sceneNpcOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const characterId = override.characterId;
|
||||
if (characterId !== undefined && (typeof characterId !== 'string' || !characterIds.has(characterId))) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] has invalid characterId "${String(characterId)}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateStateFunctionOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/stateFunctionOverrides.json');
|
||||
if (!expectPlainObject(errors, 'stateFunctionOverrides', overrides)) return;
|
||||
|
||||
const functionIds = new Set(buildStateFunctionDefinitions().map(definition => definition.id));
|
||||
Object.entries(overrides).forEach(([functionId, override]) => {
|
||||
if (!functionIds.has(functionId)) {
|
||||
errors.push(`[override] stateFunctionOverrides contains unknown function id "${functionId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] stateFunctionOverrides["${functionId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateNpcVisualOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/npcVisualOverrides.json');
|
||||
if (!expectPlainObject(errors, 'npcVisualOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] npcVisualOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] npcVisualOverrides["${npcId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectItemAssetPaths(rootDir: string, relativeDir = 'Icons'): string[] {
|
||||
const entries = readdirSync(rootDir, { withFileTypes: true });
|
||||
const collected: string[] = [];
|
||||
|
||||
entries.forEach(entry => {
|
||||
const absolutePath = path.join(rootDir, entry.name);
|
||||
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
collected.push(...collectItemAssetPaths(absolutePath, relativePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||||
collected.push(relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function validateItemOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/itemOverrides.json');
|
||||
if (!expectPlainObject(errors, 'itemOverrides', overrides)) return;
|
||||
|
||||
const validItemIds = new Set(
|
||||
collectItemAssetPaths(path.resolve(process.cwd(), 'public/Icons'))
|
||||
.map(assetPath => buildItemCatalogId(assetPath)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([itemId, override]) => {
|
||||
if (!validItemIds.has(itemId)) {
|
||||
errors.push(`[override] itemOverrides contains unknown item id "${itemId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = override.tags;
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] has invalid tags`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors: string[] = [];
|
||||
|
||||
validateCharacterOverrides(errors);
|
||||
validateMonsterOverrides(errors);
|
||||
validateSceneOverrides(errors);
|
||||
validateSceneNpcOverrides(errors);
|
||||
validateStateFunctionOverrides(errors);
|
||||
validateNpcVisualOverrides(errors);
|
||||
validateItemOverrides(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Override validation failed with ${errors.length} issue(s):`);
|
||||
errors.forEach(error => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Override validation passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
16
scripts/vite-cli.mjs
Normal file
16
scripts/vite-cli.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
if (crypto.webcrypto) {
|
||||
if (typeof crypto.getRandomValues !== 'function') {
|
||||
crypto.getRandomValues = crypto.webcrypto.getRandomValues.bind(crypto.webcrypto);
|
||||
}
|
||||
|
||||
if (!globalThis.crypto || typeof globalThis.crypto.getRandomValues !== 'function') {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: crypto.webcrypto,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await import('../node_modules/vite/bin/vite.js');
|
||||
Reference in New Issue
Block a user