Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 deletions
+4
View File
@@ -400,6 +400,10 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then
MAINTENANCE_HTML
fi
echo "[production-release] 规范 Web 静态资源权限"
find "${WEB_DIR}" -type d -exec chmod 755 {} +
find "${WEB_DIR}" -type f -exec chmod 644 {} +
echo "[production-release] 打包 Web 静态资源 -> ${TARGET_DIR}/web.tar.gz"
tar -czf "${TARGET_DIR}/web.tar.gz" -C "${TARGET_DIR}" web
write_sha256_file "${TARGET_DIR}/web.tar.gz"
+4
View File
@@ -98,6 +98,10 @@ echo "[production-web-deploy] 解压 Web 到: ${WEB_TARGET}"
tar -xzf "${SOURCE_DIR}/web.tar.gz" -C "${RELEASE_DIR}"
test -d "${WEB_TARGET}"
echo "[production-web-deploy] 规范 Web 静态资源权限"
find "${WEB_TARGET}" -type d -exec chmod 755 {} +
find "${WEB_TARGET}" -type f -exec chmod 644 {} +
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.web.json"
fi
File diff suppressed because it is too large Load Diff
+217
View File
@@ -1,7 +1,13 @@
import {createServer} from 'node:net';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {describe, expect, it} from 'vitest';
import {
findAvailablePort,
mapDevPortsToPortRange,
parsePortRangeSpec,
reserveLinuxDevPortRange,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
@@ -18,6 +24,20 @@ function reservePort(port) {
}
describe('dev stack port utils', () => {
it('解析端口段并映射到四个 dev 端口', () => {
expect(parsePortRangeSpec('10000-10099')).toEqual({
start: 10000,
end: 10099,
label: '10000-10099',
});
expect(mapDevPortsToPortRange('10000-10099')).toMatchObject({
webPort: 10000,
apiPort: 10001,
spacetimePort: 10002,
adminWebPort: 10003,
});
});
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
const firstServer = await reservePort(0);
const firstPort = firstServer.address().port;
@@ -38,6 +58,16 @@ describe('dev stack port utils', () => {
}
});
it('端口查找不会越过 Linux 用户端口段', async () => {
await expect(
findAvailablePort({
host: '127.0.0.1',
preferredPort: 9999,
portRange: {start: 10000, end: 10099, label: '10000-10099'},
}),
).rejects.toThrow('不在允许端口段');
});
it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => {
const resolvedPorts = await resolveDevStackPorts({
spacetime: {host: '127.0.0.1', preferredPort: 0},
@@ -48,4 +78,191 @@ describe('dev stack port utils', () => {
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
});
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
const rangeStart = 10000;
const rangeEnd = 10300;
const reservedPorts = new Set(
Array.from({length: 201}, (_, index) => rangeStart + index),
);
const availablePort = await findAvailablePort({
host: '127.0.0.1',
preferredPort: rangeStart,
reservedPorts,
portRange: {start: rangeStart, end: rangeEnd, label: `${rangeStart}-${rangeEnd}`},
});
expect(availablePort).toBeGreaterThan(rangeStart + 200);
expect(availablePort).toBeLessThanOrEqual(rangeEnd);
});
const linuxIt = process.platform === 'linux' ? it : it.skip;
linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const aliceAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'alice',
requestedRange: null,
registryPath,
lockPath,
});
const bobAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'bob',
requestedRange: null,
registryPath,
lockPath,
});
expect(aliceAllocation.range.label).toBe('10000-10099');
expect(aliceAllocation.source).toBe('auto');
expect(bobAllocation.range.label).toBe('10100-10199');
expect(bobAllocation.source).toBe('auto');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 系统级端口段记录会阻止两个用户拿到同一段', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
GENARRATIVE_DEV_PORT_RANGE: '10000-10099',
};
const aliceAllocation = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
expect(aliceAllocation.range.label).toBe('10000-10099');
const registryAfterAlice = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(registryAfterAlice.allocations.alice.range.label).toBe('10000-10099');
await expect(
reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'bob',
LOGNAME: 'bob',
},
username: 'bob',
requestedRange: '10000-10099',
registryPath,
lockPath,
}),
).rejects.toThrow('已被用户 alice 占用');
const aliceReuse = await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'alice',
LOGNAME: 'alice',
},
username: 'alice',
registryPath,
lockPath,
});
expect(aliceReuse.range.label).toBe('10000-10099');
expect(aliceReuse.source).toBe('manual');
expect(
(
await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '10100-10199',
},
username: 'alice',
requestedRange: '10100-10199',
registryPath,
lockPath,
})
).range.label,
).toBe('10000-10099');
const registryAfterReuse = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(registryAfterReuse.allocations.alice.range.label).toBe('10000-10099');
const bobAllocation = await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE: '10100-10199',
},
username: 'bob',
requestedRange: '10100-10199',
registryPath,
lockPath,
});
expect(bobAllocation.range.label).toBe('10100-10199');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 同一用户第二个 dev 会话会复用同一用户段并继续在段内漂移', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
GENARRATIVE_DEV_PORT_RANGE: '10000-10099',
};
const first = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
const second = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
expect(first.range.label).toBe('10000-10099');
expect(second.range.label).toBe('10000-10099');
expect(second.username).toBe('alice');
expect(JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range.label).toBe(
'10000-10099',
);
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
});
+383 -9
View File
File diff suppressed because it is too large Load Diff
+142 -4
View File
@@ -9,6 +9,7 @@ import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
@@ -16,6 +17,7 @@ import {
isSpacetimePublishPermissionError,
parseSpacetimeToolVersion,
parseArgs,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -36,6 +38,8 @@ function workspaceSpacetimeVersionForTest() {
}
describe('dev scheduler argument routing', () => {
const linuxTest = process.platform === 'linux' ? test : test.skip;
test('Windows junction 路径下的直接执行入口也能识别为当前模块', () => {
const moduleUrl =
'file:///F:/DevWorktrees/codex/worktrees/f584/Genarrative/scripts/dev.mjs';
@@ -102,6 +106,67 @@ describe('dev scheduler argument routing', () => {
'http://127.0.0.1:3100',
);
});
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs([], {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
});
const runner = new DevRunner(options, {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(22000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const {command, explicitOptions, options} = parseArgs([], {
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
});
const runner = new DevRunner(options, {
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange).toBeNull();
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
});
describe('dev scheduler api-server env', () => {
@@ -119,6 +184,79 @@ describe('dev scheduler api-server env', () => {
});
});
describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
join('C:\\repo\\Genarrative', '.app/dev-stack.json'),
);
});
test('状态快照记录服务 pid、端口、URL 和当前命令', () => {
const updatedAt = '2026-05-29T00:00:00.000Z';
const runner = {
command: 'web',
options: {
apiHost: '127.0.0.1',
apiPort: 8090,
webHost: '0.0.0.0',
webPort: 3010,
adminWebHost: '127.0.0.1',
adminWebPort: 3110,
spacetimeHost: '127.0.0.1',
spacetimePort: 3120,
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
database: 'genarrative-test',
watch: false,
},
state: {
apiTarget: 'http://127.0.0.1:8090',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
},
services: new Map([
[
'web',
{
child: {pid: 4321},
runtime: {
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
startedAt: updatedAt,
updatedAt,
exitCode: null,
signal: null,
},
},
],
]),
};
const snapshot = buildDevStackSnapshot(runner, updatedAt);
expect(snapshot.schemaVersion).toBe(1);
expect(snapshot.command).toBe('web');
expect(snapshot.database).toBe('genarrative-test');
expect(snapshot.services.web).toMatchObject({
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
});
expect(snapshot.services['api-server']).toMatchObject({
status: 'idle',
pid: null,
port: 8090,
url: 'http://127.0.0.1:8090',
});
});
});
describe('dev scheduler spacetime reuse guard', () => {
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
@@ -266,18 +404,18 @@ describe('dev scheduler watch routing', () => {
describe('dev scheduler spacetime refresh', () => {
test('解析 spacetime --version 输出里的 tool version', () => {
const version = parseSpacetimeToolVersion(`
A new version of SpacetimeDB is available: v2.2.0 (current: v2.1.0)
spacetimedb tool version 2.2.0; spacetimedb-lib version 2.2.0;
A new version of SpacetimeDB is available: v2.3.0 (current: v2.2.0)
spacetimedb tool version 2.3.0; spacetimedb-lib version 2.3.0;
`);
expect(version).toBe('2.2.0');
expect(version).toBe('2.3.0');
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.2.0',
workspaceVersion: '2.3.0',
}),
).toThrow('procedure 返回值 BSATN 反序列化失败');
});
+36 -1
View File
@@ -8,6 +8,16 @@ set -euo pipefail
ORIGINAL_HOME="${HOME:-}"
CARGO_BUILD_HOME="${CARGO_BUILD_HOME:-$(dirname "${CARGO_HOME}")/home}"
USER_CARGO_CONFIG=""
WORKSPACE_TMP_ROOT="${WORKSPACE_TMP:-}"
if [[ -z "${WORKSPACE_TMP_ROOT}" && -n "${WORKSPACE:-}" ]]; then
WORKSPACE_TMP_ROOT="${WORKSPACE}@tmp"
fi
if [[ -z "${WORKSPACE_TMP_ROOT}" ]]; then
WORKSPACE_TMP_ROOT="${CARGO_BUILD_HOME}"
fi
SCCACHE_SERVER_UDS="${SCCACHE_SERVER_UDS:-${WORKSPACE_TMP_ROOT}/sccache.sock}"
SCCACHE_IDLE_TIMEOUT="${SCCACHE_IDLE_TIMEOUT:-0}"
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" "${CARGO_BUILD_HOME}"
@@ -25,20 +35,45 @@ for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/
fi
done
for candidate in \
"${ORIGINAL_HOME}/.cargo/config.toml" \
"${ORIGINAL_HOME}/.cargo/config" \
"/data/jenkins/.cargo/config.toml" \
"/data/jenkins/.cargo/config" \
"/etc/cargo/config.toml" \
"/etc/cargo/config"
do
if [[ -f "${candidate}" ]]; then
USER_CARGO_CONFIG="${candidate}"
break
fi
done
export HOME="${CARGO_BUILD_HOME}"
export CARGO_HOME
export CARGO_TARGET_DIR
export SCCACHE_DIR
export SCCACHE_SERVER_UDS
export SCCACHE_IDLE_TIMEOUT
cat >"${CARGO_HOME}/config.toml" <<'CARGO_CONFIG'
if [[ -n "${USER_CARGO_CONFIG}" ]]; then
install -m 0644 "${USER_CARGO_CONFIG}" "${CARGO_HOME}/config.toml"
else
cat >"${CARGO_HOME}/config.toml" <<'CARGO_CONFIG'
[registries.crates-io]
protocol = "sparse"
CARGO_CONFIG
fi
echo "[cargo-env] HOME=${HOME}"
echo "[cargo-env] CARGO_HOME=${CARGO_HOME}"
echo "[cargo-env] CARGO_TARGET_DIR=${CARGO_TARGET_DIR}"
echo "[cargo-env] SCCACHE_DIR=${SCCACHE_DIR}"
echo "[cargo-env] SCCACHE_SERVER_UDS=${SCCACHE_SERVER_UDS}"
echo "[cargo-env] SCCACHE_IDLE_TIMEOUT=${SCCACHE_IDLE_TIMEOUT}"
if [[ -n "${USER_CARGO_CONFIG}" ]]; then
echo "[cargo-env] USER_CARGO_CONFIG=${USER_CARGO_CONFIG}"
fi
if [[ -n "${RUSTUP_HOME:-}" ]]; then
echo "[cargo-env] RUSTUP_HOME=${RUSTUP_HOME}"
fi
+50 -5
View File
@@ -17,6 +17,8 @@ type MiniProgramPage = {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>) => void;
onLoad: (query?: Record<string, string>) => Promise<void>;
onShow: () => void;
consumePayResult: () => void;
};
function createWxMock() {
@@ -44,6 +46,10 @@ function loadWebViewPage(
Page(config: Record<string, unknown>) {
pageConfig = config;
},
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (requestPath === '../../config') {
return {
@@ -85,22 +91,40 @@ describe('mini-program web-view auth page', () => {
vi.clearAllMocks();
});
test('默认进入时直接打开 web-view,不触发微信登录请求', async () => {
test('默认进入时刷新微信小程序登录态后打开 web-view', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
});
wxMock.request.mockImplementation(({ success }) => {
success({
statusCode: 200,
data: {
token: 'jwt-active-wechat',
bindingStatus: 'active',
},
});
});
const page = loadWebViewPage(wxMock);
await page.onLoad({});
expect(wxMock.login).not.toHaveBeenCalled();
expect(wxMock.request).not.toHaveBeenCalled();
expect(wxMock.login).toHaveBeenCalledTimes(1);
expect(wxMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login',
method: 'POST',
data: { code: 'wx-login-code' },
}),
);
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(false);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active',
);
});
test('默认匿名进入 web-view 不依赖 API_BASE_URL 配置', async () => {
test('默认匿名进入 web-view 不依赖 API_BASE_URL 配置', async () => {
const wxMock = createWxMock();
const page = loadWebViewPage(wxMock, {
API_BASE_URL: '',
@@ -116,6 +140,27 @@ describe('mini-program web-view auth page', () => {
);
});
test('onShow 二次检查支付结果并写回 web-view hash', () => {
const wxMock = createWxMock();
wxMock.getStorageSync.mockImplementation((key) =>
key === 'genarrative:wechat-pay-result'
? 'request-1:success:order-1'
: '',
);
const page = loadWebViewPage(wxMock);
page.data.webViewUrl =
'https://www.genarrative.world/?clientType=mini_program#tab=profile';
page.onShow();
expect(wxMock.removeStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-pay-result',
);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
);
});
test('H5 请求登录时才启动微信小程序登录并进入手机号授权态', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {