refactor(游戏共创): Fork 口径收敛为「把整个工程复制到你的项目里继续改造」
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

产品裁定:不再对外解释「可玩参考 + 素材 / 不能一键复刻工程 / 需自建合规工程」那套口径。
统一一句话:**Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理)**,动作名统一
叫 Fork。必要告知只保留一句:开启共创并上传工程时,整个工程(含素材与文档)会对他人可见,
且不可撤销。

- 入口(首页):`从平台作品 Fork` + 角标 `Fork`,说明压成一句「把平台上的作品整个工程复制到
  你的项目里继续改造(像 git fork)。输入要 Fork 的作品 ID 或作品链接即可。」;按钮
  `Fork 到我的项目`、加载态「正在 Fork:下载、校验并创建项目…」;成功态只留
  「已创建项目《X》,Fork 自平台作品 <gameId>(来源已记录在 …fork-source.json)」,
  删掉按取件形态分岔的长句。
- 发布面板:授权区只留一句「授权非「禁止共创」即允许他人 Fork 你的作品;开启共创并上传工程时,
  整个工程(含素材与文档)会对他人可见,且不可撤销。」;确认项压成一句
  「我已确认工程里没有不愿公开的内容,同意随本次发布公开整个工程(含素材与文档);一经发布
  不可撤销。」,其后一行写「默认已勾选,可随时取消;不勾选则本次不上传工程源码。」
- 错误与进度文案:取件失败四态改为 Fork 措辞(`无法 Fork` / `该作品当前不能被 Fork(未公开、
  已下架或没有公开版本)`),读取/下载/响应类错误改为 `读取 Fork 信息失败` / `下载 Fork 内容失败`,
  超限提示改为「Fork 内容超过客户端下载上限,无法完成」;「未上传工程源码」提示改为
  「他人 Fork 时只能拿到平台已发布的成品」;默认项目名前缀 `改编项目` → `Fork 项目`;
  记录标签 `项目改编来源记录` → `项目 Fork 来源记录`。
- 实现事实(成品包形态的脚手架 + `reference/<gameId>/`)保留在 Rust 文档注释里,不再作为对外
  表述;`collect_project_bundle_files` 等实现注释不动。
- 测试:入口用例改为断言一句话口径与「Fork 自平台作品」并断言旧的解释性表述已消失;面板用例
  断言新的简短告知与「默认已勾选,可随时取消;不勾选则本次不上传工程源码」;错误文案断言同步。
This commit is contained in:
2026-10-06 00:55:53 +08:00
parent 928fb0e616
commit 4cf00ac07d
11 changed files with 123 additions and 143 deletions
@@ -61,7 +61,7 @@ values!(CreationSource {
HomeDesign,
Template,
SelectedDirectory,
// 从平台作品开始创作(改编 Fork):取件自平台已授权公开作品的发行成品包。
// 从平台作品 Fork:把平台作品的工程复制成本地项目继续改造。
PlatformGame
});
values!(OpenSource {
@@ -533,17 +533,17 @@ fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string()
}
StatusCode::FORBIDDEN => {
"permission-denied: 作者没有开放这个作品的共创授权,无法改编".to_string()
"permission-denied: 作者没有开放这个作品的共创授权,无法 Fork".to_string()
}
StatusCode::NOT_FOUND => {
"fork-source-not-found: 该作品不存在或已被删除,无法改编".to_string()
"fork-source-not-found: 该作品不存在或已被删除,无法 Fork".to_string()
}
StatusCode::CONFLICT => {
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
"fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)"
.to_string()
}
_ => format!(
"读取改编来源失败:{}",
"读取 Fork 信息失败:{}",
server_error_detail(code, message, || format!("HTTP {}", status.as_u16()))
),
}
@@ -553,10 +553,10 @@ fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
fn fork_source_download_segments(download_path: &str) -> Result<Vec<String>, String> {
let trimmed = download_path.trim();
if !trimmed.starts_with('/') || trimmed.starts_with("//") {
return Err("改编来源下载路径无效".to_string());
return Err("Fork 内容下载路径无效".to_string());
}
if trimmed.contains(['\\', '?', '#']) || trimmed.contains("://") {
return Err("改编来源下载路径无效".to_string());
return Err("Fork 内容下载路径无效".to_string());
}
let segments = trimmed
.split('/')
@@ -568,7 +568,7 @@ fn fork_source_download_segments(download_path: &str) -> Result<Vec<String>, Str
.iter()
.any(|segment| segment == "." || segment == ".." || segment.contains(':'))
{
return Err("改编来源下载路径无效".to_string());
return Err("Fork 内容下载路径无效".to_string());
}
Ok(segments)
}
@@ -600,9 +600,9 @@ async fn request_fork_source_metadata(
.await
.map_err(|error| {
if error.is_timeout() {
"读取改编来源失败:请求超时,请稍后重试".to_string()
"读取 Fork 信息失败:请求超时,请稍后重试".to_string()
} else {
"读取改编来源失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
"读取 Fork 信息失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
.to_string()
}
})?;
@@ -610,12 +610,12 @@ async fn request_fork_source_metadata(
let text = response
.text()
.await
.map_err(|error| format!("读取改编来源失败:读取响应失败:{error}"))?;
.map_err(|error| format!("读取 Fork 信息失败:读取响应失败:{error}"))?;
validate_session(snapshot)?;
if !status.is_success() {
return Err(map_fork_source_http_error(status, &text));
}
response_data(&text).map_err(|error| format!("读取改编来源失败:{error}"))
response_data(&text).map_err(|error| format!("读取 Fork 信息失败:{error}"))
}
/// 取件本体(`GET {downloadPath}`):整包进内存,超过契约上限即失败关闭。
@@ -647,9 +647,9 @@ async fn request_fork_source_package(
.await
.map_err(|error| {
if error.is_timeout() {
"下载改编来源失败:请求超时,请稍后重试".to_string()
"下载 Fork 内容失败:请求超时,请稍后重试".to_string()
} else {
"下载改编来源失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
"下载 Fork 内容失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
.to_string()
}
})?;
@@ -663,14 +663,14 @@ async fn request_fork_source_package(
.content_length()
.is_some_and(|length| length > max_bytes)
{
return Err("改编来源发行包超过客户端下载上限,无法改编".to_string());
return Err("Fork 内容超过客户端下载上限,无法完成".to_string());
}
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| format!("下载改编来源失败:读取响应失败:{error}"))?;
let chunk = chunk.map_err(|error| format!("下载 Fork 内容失败:读取响应失败:{error}"))?;
if body.len() as u64 + chunk.len() as u64 > max_bytes {
return Err("改编来源发行包超过客户端下载上限,无法改编".to_string());
return Err("Fork 内容超过客户端下载上限,无法完成".to_string());
}
body.extend_from_slice(&chunk);
}
@@ -692,7 +692,7 @@ pub(crate) async fn fetch_platform_game_fork_source(
character.is_ascii_alphanumeric() || character == '-' || character == '_'
})
{
return Err("改编来源作品标识无效".to_string());
return Err("Fork 来源作品 ID 无效".to_string());
}
let snapshot = require_platform_session()?;
let client = build_client()?;
@@ -701,15 +701,15 @@ pub(crate) async fn fetch_platform_game_fork_source(
value
.get("forkSource")
.cloned()
.ok_or_else(|| "改编来源响应缺少来源信息,请稍后重试".to_string())?,
.ok_or_else(|| "Fork 信息响应缺少来源信息,请稍后重试".to_string())?,
)
.map_err(|error| format!("改编来源响应无效:{error}"))?;
.map_err(|error| format!("Fork 信息响应无效:{error}"))?;
if source.game_id.trim().is_empty() || source.version_id.trim().is_empty() {
return Err("改编来源响应无效:缺少作品或版本标识".to_string());
return Err("Fork 信息响应无效:缺少作品或版本标识".to_string());
}
if source.bytes == 0 || source.bytes > FORK_SOURCE_PACKAGE_MAX_BYTES {
return Err(format!(
"改编来源发行包大小异常({} 字节),客户端无法下载",
"Fork 内容大小异常({} 字节),客户端无法下载",
source.bytes
));
}
@@ -1781,7 +1781,7 @@ pub(crate) async fn publish_local_project_game(
)
.await
.err()
.map(|error| format!("未上传工程源码,本作品只能被产物级改编:{error}"))
.map(|error| format!("未上传工程源码,他人 Fork 时只能拿到平台已发布的成品:{error}"))
} else {
None
};
@@ -2015,21 +2015,21 @@ mod tests {
StatusCode::FORBIDDEN,
r#"{"error":{"code":"FORK_NOT_AUTHORIZED","message":"未开放共创"}}"#
),
"permission-denied: 作者没有开放这个作品的共创授权,无法改编"
"permission-denied: 作者没有开放这个作品的共创授权,无法 Fork"
);
assert_eq!(
map_fork_source_http_error(
StatusCode::NOT_FOUND,
r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"#
),
"fork-source-not-found: 该作品不存在或已被删除,无法改编"
"fork-source-not-found: 该作品不存在或已被删除,无法 Fork"
);
assert_eq!(
map_fork_source_http_error(
StatusCode::CONFLICT,
r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"#
),
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
"fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)"
);
// 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。
assert_eq!(
@@ -2037,7 +2037,7 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR,
r#"{"error":{"code":"INTERNAL","message":"服务异常"}}"#
),
"读取改编来源失败:服务异常"
"读取 Fork 信息失败:服务异常"
);
}
@@ -1,16 +1,16 @@
//! 从平台作品开始创作(成品包路径):受鉴权取件 → 摘要校验 → 合规脚手架 + 参考副本 + 来源记录。
//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造的工程。
//!
//! 边界(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 / §3.5.4 路线 A 一致):
//! 平台下发的是**已构建的发行成品包**(只有 `dist` 产物、没有 `package.json` 也没有源码),
//! 它只能当「可玩参考 + 素材来源」,**不能**变成可直接发布的项目。因此这里建立的是
//! 「合规的 Phaser4 + Vite 脚手架」+「子目录里的参考副本」,用户在脚手架上自己改造。
//! 两种取件形态(服务端 `source`)落法不同,实现事实如下:
//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造;
//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品放进
//! `<project>/reference/<gameId>/`,用户在新工程里继续改造。
//!
//! 关键顺序(顺序本身就是合同):
//! 1. 先取件并按元数据校验字节(失败关闭,不落盘);
//! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是
//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以参考产物
//! 必须等脚手架生成之后再铺,且只能铺进子目录;
//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明改编来源。
//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以 `Package`
//! 形态的成品必须等脚手架生成之后再铺,且只能铺进子目录;
//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。
#[cfg(not(test))]
mod desktop;
@@ -32,13 +32,13 @@ pub(crate) fn verify_fork_source_bytes(
) -> Result<(), String> {
if bytes.len() as u64 != expected_bytes {
return Err(format!(
"改编来源发行包大小校验失败(期望 {expected_bytes} 字节,实际 {} 字节),已放弃落盘",
"Fork 内容大小校验失败(期望 {expected_bytes} 字节,实际 {} 字节),已放弃落盘",
bytes.len()
));
}
let actual = crate::project_bundle::sha256_hex(bytes);
if actual != expected_sha256.trim().to_ascii_lowercase() {
return Err("改编来源发行包完整性校验失败,已放弃落盘".to_string());
return Err("Fork 内容完整性校验失败,已放弃落盘".to_string());
}
Ok(())
}
@@ -53,15 +53,15 @@ pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, Str
// 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被
// 归一成 `reference`,那不是某个作品的参考目录。
if game_id.is_empty() {
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string());
}
let relative = format!("{FORK_REFERENCE_DIRECTORY_NAME}/{game_id}");
let relative = crate::template_library::safe_archive_relative_path(&relative)
.map_err(|_| "改编来源作品标识无效,无法确定参考副本目录".to_string())?;
.map_err(|_| "Fork 来源作品 ID 无效,无法确定参考副本目录".to_string())?;
// 只接受「`reference` + 单一作品目录」两段:带 `/` 的标识会变成嵌套目录,既可能撞上
// 另一个作品的目录,也不是平台作品 ID 的形状。
if relative.components().count() != 2 {
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string());
}
Ok(relative)
}
@@ -69,11 +69,11 @@ pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, Str
/// 用平台作品的取件内容建一个新项目。
///
/// 两种取件形态(`source`)落法不同:
/// - `Package`(已构建成品包):先按标准初始化生成合规脚手架,再把参考副本铺进
/// `<project>/reference/<gameId>/`——成品包没有源码,只能当「可玩参考 + 素材」。
/// - `Project`(工程源包):包本身就是可编辑工程(自带 `package.json` / `vite.config.*`),
/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进
/// `<project>/reference/<gameId>/`——平台只有成品时只有这一份内容可用。
/// - `Project`(工程源包):包本身就是作者的工程(自带 `package.json` / `vite.config.*`),
/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/package.json` 已存在),
/// 只补 `.agent` 身份、目录与 agent.db,得到一份能继续改、能重新构建的工程(方案 §3.5.2)。
/// 只补 `.agent` 身份、目录与 agent.db。
///
/// 任一步失败都删掉半成品目录,不留无法解释的项目。
#[allow(clippy::too_many_arguments)]
@@ -113,7 +113,7 @@ pub(crate) fn create_project_from_platform_fork_at(
let prefix = if planning {
"策划项目"
} else {
"改编项目"
"Fork 项目"
};
format!("{prefix} {short_id}")
});
@@ -133,7 +133,7 @@ pub(crate) fn create_project_from_platform_fork_at(
package_bytes,
&project_root,
)
.map_err(|error| format!("改编来源工程源包解压失败:{error}"))?;
.map_err(|error| format!("Fork 工程源包解压失败:{error}"))?;
let project = init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
@@ -166,7 +166,7 @@ pub(crate) fn create_project_from_platform_fork_at(
package_bytes,
&reference_root,
)
.map_err(|error| format!("改编来源发行包解压失败:{error}"))?;
.map_err(|error| format!("Fork 内容解压失败:{error}"))?;
write_project_fork_source(
&project_root,
&ProjectForkSourceRecord::new(
@@ -3,11 +3,11 @@ use super::*;
use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session};
use shared_contracts::game_distribution::GameDistributionForkSourceKind;
/// 从平台作品开始创作:按取件形态建成项目。
/// 从平台作品 Fork:按取件形态建成项目。
///
/// - `source: "project"`(工程源包):解压即得可编辑工程,再走标准初始化补 `.agent` 身份。
/// - `source: "package"`(成品包):生成合规 Phaser 4 + Vite 脚手架,成品包作可玩参考放进
/// `reference/<gameId>/`——它没有源码,不能直接再次发布。
/// - `source: "project"`(工程源包):解压即得作者的工程,再走标准初始化补 `.agent` 身份。
/// - `source: "package"`(成品包):平台只有成品时生成脚手架,成品放进
/// `reference/<gameId>/`。
///
/// 失败关闭点(按顺序):
/// 1. 没有平台登录态 → 直接给出可操作的登录提示,不发起任何请求;
@@ -25,7 +25,7 @@ pub(crate) async fn create_local_project_from_platform_game(
let analytics_context = crate::analytics::gui::capture_analytics_context();
let game_id = game_id.trim().to_string();
if game_id.is_empty() {
return Err("请先选择要改编的平台作品".to_string());
return Err("请先选择要 Fork 的平台作品".to_string());
}
// 先冻结会话身份:取件与建项之间换号必须失败关闭,不能把 A 的取件结果落成 B 的项目。
let session = require_platform_session()?;
@@ -1,7 +1,7 @@
//! 项目改编来源记录:`.agent/fork-source.json`。
//! 项目 Fork 来源记录:`.agent/fork-source.json`。
//!
//! 承载「本项目改编自平台作品 X」这一事实:由「从平台作品开始创作」在取件校验通过、
//! 参考副本铺好之后写入,由发布链路的首次发布读取并并入创建作品请求的 `fork` 声明。
//! 承载「本项目 Fork 自平台作品 X」这一事实:由「从平台作品 Fork」在取件校验通过、
//! 建项完成之后写入,由发布链路的首次发布读取并并入创建作品请求的 `fork` 声明。
//!
//! 落点选择(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 一致):
//! - `.agent/**` 随项目快照上云、随用户整目录拷贝,是唯一既「随行」又不干扰游戏产物的位置;
@@ -22,27 +22,27 @@ pub(crate) const FORK_SOURCE_SCHEMA_VERSION: &str = "agc-fork-source.v2";
/// 仍接受的旧版本:v1 没有 `source`,按「成品包」解释(当时只有这一条取件通道)。
pub(crate) const FORK_SOURCE_SCHEMA_VERSION_V1: &str = "agc-fork-source.v1";
/// 取件形态:工程源包(可编辑工程)还是已构建成品包(只能作可玩参考 + 素材)。
/// 取件形态:工程源包(作者的工程)还是已构建成品包。
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ProjectForkSourceKind {
/// 已构建的发行成品包:AGC 侧只能「合规脚手架 + 参考副本」。
/// 已构建的成品包:AGC 侧生成脚手架,并把成品放进 `reference/<gameId>/`。
#[default]
Package,
/// 工程源包:解压即得可编辑工程,不需要再生成脚手架。
/// 工程源包:解压即得作者的工程,不需要再生成脚手架。
Project,
}
/// 相对项目根的位置:`.agent` 是项目身份与 Agent 状态的权威目录,整目录随项目同步。
pub(crate) const FORK_SOURCE_RELATIVE_PATH: &str = ".agent/fork-source.json";
const FORK_SOURCE_LABEL: &str = "项目改编来源记录";
const FORK_SOURCE_LABEL: &str = "项目 Fork 来源记录";
/// 「本项目改编自平台作品」的本地记录。
/// 「本项目 Fork 自平台作品」的本地记录。
///
/// `schema_version` 必填;`game_id` / `version_id` 是平台侧的作品身份与建立血缘时锁定的父版本,
/// 发布时原样并入创建作品请求的 `fork` 字段。`source` 从 v2 起记录取件形态;v1 记录没有该字段,
/// 读回来按「成品包」(当时只有这一条通道),因此老项目仍能正确声明改编来源。
/// 读回来按「成品包」(当时只有这一条通道),因此老项目仍能正确声明 Fork 来源。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct ProjectForkSourceRecord {
@@ -52,7 +52,7 @@ pub(crate) struct ProjectForkSourceRecord {
/// 取件形态;v1 记录缺省 → 成品包。
#[serde(default)]
pub(crate) source: ProjectForkSourceKind,
/// 取件成功并铺好参考副本的时刻(epoch 毫秒),只作本地审计,不参与任何上传。
/// 建项成功的时刻(epoch 毫秒),只作本地审计,不参与任何上传。
pub(crate) adopted_at_millis: u64,
}
@@ -977,12 +977,12 @@ export function GameDistributionPublishPanel({
<p className="game-distribution-publish-panel__fork-hint">
{forkAuthorizationLocked
? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。'
: '授权非「禁止共创」即允许他人改编(没有单独的源码可见性开关);是否随本次发布把工程源码一起交出去,由下面的确认决定。上架后授权只能单向提升,不能撤销。'}
: '授权非「禁止共创」即允许他人 Fork 你的作品;开启共创并上传工程时,整个工程(含素材与文档)会对他人可见,且不可撤销。'}
</p>
{/*
工程源码外发是**知情同意**:工程源包的拒绝规则是黑名单,作者的 docs/、截图、
设计稿、笔记等不在任何拒绝集内,会随包一起对任何登录用户公开。因此这里不是
一句免责声明,而是必须由作者主动确认的动作(默认不勾选,不勾选就不上传源码)。
设计稿、笔记等不在任何拒绝集内,会随包一起对任何登录用户公开。默认勾选与授权
语义一致(允许共创即开放源码),但作者可以取消:取消即本次不上传源码。
*/}
{forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? (
<label className="game-distribution-publish-panel__bundle-consent">
@@ -994,20 +994,15 @@ export function GameDistributionPublishPanel({
}
/>
<span>
我已确认项目里没有不愿公开的内容,同意随本次发布公开
<strong>整个项目目录</strong>
:源码、素材与其中其它文件(含文档、截图、设计稿、笔记等)都会对任何登录用户公开;
只会自动排除依赖目录、版本库、构建产物与常见凭据文件。一经发布
<strong>不可撤销</strong>
:即使之后把授权降级,也无法收回别人已经下载的内容。
我已确认工程里没有不愿公开的内容,同意随本次发布公开
<strong>整个工程(含素材与文档)</strong>
;一经发布<strong>不可撤销</strong>。
</span>
</label>
) : null}
{forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? (
<p className="game-distribution-publish-panel__fork-hint">
{'上面这项'}
<strong>默认已勾选</strong>
{'(平台按「允许共创即开放源码」的默认帮你勾上,不是替你做决定——你可以随时取消)。不勾选则本次不上传工程源码:作品仍可正常发布,但他人只能拿到已构建成品试玩与参考,不能直接重新发布。'}
{'默认已勾选,可随时取消;不勾选则本次不上传工程源码。'}
</p>
) : null}
</fieldset>
@@ -1,5 +1,5 @@
/**
* 「从平台作品开始创作」的纯逻辑:输入解析与失败分类。
* 「从平台作品 Fork」的纯逻辑:输入解析与失败分类。
*
* 与原生取件的分工:这里只做**能在渲染层判定的格式问题**(空、非法字符、链接里找不到作品
* ID)——判不了就不发请求;作品的可见性、授权与存在性一律以服务端为准(原生命令的
@@ -101,4 +101,4 @@ export function classifyPlatformForkFailure(error: unknown): PlatformForkFailure
/** 输入非法(解析不出作品 ID)时的统一提示:本地拦截,不发请求。 */
export const PLATFORM_FORK_INPUT_ERROR =
'请输入平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)';
'请输入要 Fork 的平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)';
@@ -1,11 +1,7 @@
/**
* 首页「从平台作品开始创作」入口:输入平台作品 ID 或作品链接,取件并新建本地项目。
*
* 产品口径(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §2.5 / §3.5.1 一致):
* 平台下发的是**已构建的发行成品包**,只能当「可玩参考 + 素材来源」,**不是可编辑的工程**,
* 也不能直接再次发布;参考产物铺在新项目的 `reference/<作品ID>/` 下,用户要在新建的合规工程
* (Phaser 4 + Vite 脚手架)里自己改造后发布。这里绝不写成「一键复刻工程」。
* 首页「从平台作品 Fork」入口:输入平台作品 ID 或作品链接,把该作品的工程复制成本地项目。
*
* 对外口径只有一句:**Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理)**。
* 取件、摘要校验、解压与来源记录全在原生命令里(`create_local_project_from_platform_game`),
* 渲染层只做输入解析、加载态与失败分类。
*/
@@ -110,26 +106,20 @@ export default function PlatformGameForkSection({
return (
<section
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="从平台作品开始创作"
aria-label="从平台作品 Fork"
>
<header className="flex items-center justify-start gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
从平台作品开始创作
从平台作品 Fork
</h2>
<span className="rounded-full bg-(--platform-neutral-bg) px-2 py-0.5 text-[11px] text-(--platform-warm-text)">
参考改编
Fork
</span>
</header>
{/*
口径必须写清楚:拿到的是成品包(可玩参考 + 素材),不是可编辑工程,也不能直接再发布。
否则用户会以为「一键复刻」之后可以直接上架。
*/}
{/* 一句话口径,不再展开解释:Fork 就是把整个工程复制一份到你的项目里继续改造。 */}
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
输入平台上已开放共创的作品 ID 或作品链接。我们会取回该作品的
<strong className="text-(--platform-text-strong)">已构建成品</strong>
作为可玩参考与素材,放进新项目(合规的 Phaser 4 + Vite 工程)的{' '}
<code>reference/&lt;作品ID&gt;/</code>{' '}
目录。成品包里没有源码,不能直接再次发布,请在新工程里改造后发布。
把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。输入要 Fork
的作品 ID 或作品链接即可。
</p>
<form
className="flex items-center gap-2.5 max-[760px]:flex-wrap"
@@ -148,7 +138,7 @@ export default function PlatformGameForkSection({
type="submit"
disabled={busy || input.trim().length === 0}
>
{busy ? '正在取回…' : '取回并创建项目'}
{busy ? '正在 Fork…' : 'Fork 到我的项目'}
</button>
</form>
{busy ? (
@@ -156,7 +146,7 @@ export default function PlatformGameForkSection({
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
role="status"
>
正在取回作品成品、校验摘要并创建项目;发行包较大时下载与解压需要几分钟,请不要关闭客户端。
正在 Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。
</p>
) : null}
{error ? (
@@ -169,10 +159,8 @@ export default function PlatformGameForkSection({
className="m-0 text-[12px] leading-5 text-(--platform-text-strong)"
role="status"
>
已创建项目《{success.projectName}》,改编自平台作品 {success.gameId}
(来源已记录在项目的 .agent/fork-source.json)。平台下发工程源包时新的项目
就是可编辑工程,直接改造即可;只下发已构建成品时,成品放在 reference/
{success.gameId}/ 作可玩参考,请在新工程里改造后发布。
已创建项目《{success.projectName}》,Fork 自平台作品 {success.gameId}
(来源已记录在项目的 .agent/fork-source.json)。
</p>
) : null}
</section>
@@ -130,7 +130,7 @@ type HomeViewProps = {
templateLibraryError: string;
onTemplateLibraryOpen: () => void;
/**
* 「从平台作品开始创作」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目
* 「从平台作品 Fork」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目
* 并进入工作区的路径,首页只透传结果与项目打开埋点。
*/
onPlatformGameProjectCreated: (
@@ -472,8 +472,8 @@ export default function HomeView({
) : null}
{/*
「从平台作品开始创作」放在模板库之后:两者都是「从现成内容起步」,但这条走平台作品的
已授权成品包(可玩参考 + 素材),不是可编辑工程,因此与模板库分开成节。
「从平台作品 Fork」放在模板库之后:两者都是「从现成内容起步」,但这条走的是平台作品
已公开的工程,因此与模板库分开成节。
*/}
<PlatformGameForkSection onProjectCreated={onPlatformGameProjectCreated} />
</div>
@@ -402,24 +402,21 @@ describe('GameDistributionPublishPanel', () => {
const consent = await screen.findByRole('checkbox');
// 产品口径:允许共创即开放源码,不设独立开关 → 默认已勾选(作者可主动取消)。
expect(consent).toHaveProperty('checked', true);
// 告知必须如实:整个项目目录会外发、只自动排除少数几类、且不可撤销。
expect(screen.getByText(/我已确认项目里没有不愿公开的内容/u)).not.toBeNull();
expect(screen.getByText(/整个项目目录/u)).not.toBeNull();
expect(screen.getByText(/源码、素材与其中其它文件/u)).not.toBeNull();
expect(screen.getByText(/只会自动排除依赖目录、版本库、构建产物与常见凭据文件/u)).not.toBeNull();
expect(screen.getByText(/不可撤销/u)).not.toBeNull();
expect(screen.getByText(/无法收回别人已经下载的内容/u)).not.toBeNull();
// 默认值是平台替你勾上的,这一点要写出来,避免作者以为平台偷偷替他决定。
expect(screen.getByText(/默认已勾选/u)).not.toBeNull();
expect(screen.getByText(/你可以随时取消/u)).not.toBeNull();
// 取消的后果也要在发布前就说清楚。
// 必要告知只留一句:整个工程(含素材与文档)会可见且不可撤销(确认项与授权说明各出现一次)。
expect(screen.getByText(/我已确认工程里没有不愿公开的内容/u)).not.toBeNull();
expect(
screen.getByText(/不勾选则本次不上传工程源码/u),
).not.toBeNull();
// 授权说明与确认动作同一口径:没有独立的源码可见性开关。
expect(
screen.getByText(/没有单独的源码可见性开关/u),
).not.toBeNull();
screen.getAllByText(/整个工程(含素材与文档)/u).length,
).toBeGreaterThan(0);
expect(screen.getAllByText(/不可撤销/u).length).toBeGreaterThan(0);
// 授权区那一句必要告知同样保留(与 git fork 口径一致)。
expect(screen.getByText(/允许他人 Fork 你的作品/u)).not.toBeNull();
expect(screen.getByText(/会对他人可见/u)).not.toBeNull();
// 默认值与「可退出」的事实要写出来。
expect(screen.getByText(/默认已勾选,可随时取消/u)).not.toBeNull();
expect(screen.getByText(/不勾选则本次不上传工程源码/u)).not.toBeNull();
// 旧的解释性段落(参考副本 / 能不能再发布)应当已经删干净。
expect(screen.queryByText(/可玩参考/u)).toBeNull();
expect(screen.queryByText(/不能直接重新发布/u)).toBeNull();
// 默认勾选 → 不携带 includeProjectBundle,交给原生按「授权非禁止」执行上传。
await selectCover();
@@ -20,7 +20,7 @@ const FORK_COMMAND = 'create_local_project_from_platform_game';
const PROJECT_RESULT = {
projectPath: 'C:\\projects\\gameagent-fork-1',
manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
manifest: { name: '改编项目 1' },
manifest: { name: 'Fork 项目 1' },
} as unknown as InitLocalProjectResult;
type InvokeCall = { command: string; args?: Record<string, unknown> };
@@ -110,22 +110,22 @@ describe('游戏共创入口输入解析', () => {
});
expect(
classifyPlatformForkFailure(
'permission-denied: 作者没有开放这个作品的共创授权,无法改编',
'permission-denied: 作者没有开放这个作品的共创授权,无法 Fork',
),
).toEqual({
kind: 'forbidden',
message: '作者没有开放这个作品的共创授权,无法改编',
message: '作者没有开放这个作品的共创授权,无法 Fork',
});
expect(
classifyPlatformForkFailure(
'fork-source-not-found: 该作品不存在或已被删除,无法改编',
'fork-source-not-found: 该作品不存在或已被删除,无法 Fork',
),
).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法改编' });
).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法 Fork' });
expect(
classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)'),
classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)'),
).toEqual({
kind: 'unavailable',
message: '该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
message: '该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
});
// 未命中前缀:原样展示,不吞掉排障信息。
expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({
@@ -143,7 +143,7 @@ describe('PlatformGameForkSection', () => {
submitForm();
expect(forkCalls(calls)).toHaveLength(0);
expect(screen.getByRole('alert').textContent).toContain(
'请输入平台作品 ID 或作品链接',
'请输入要 Fork 的平台作品 ID 或作品链接',
);
typeGameInput(' ');
@@ -154,13 +154,13 @@ describe('PlatformGameForkSection', () => {
submitForm();
expect(forkCalls(calls)).toHaveLength(0);
expect(screen.getByRole('alert').textContent).toContain(
'请输入平台作品 ID 或作品链接',
'请输入要 Fork 的平台作品 ID 或作品链接',
);
});
it('403 未开放授权:展示服务端文案、不建项目且可重试', async () => {
installInvoke(() => {
throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法改编');
throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法 Fork');
});
const { onProjectCreated } = renderSection();
@@ -168,17 +168,17 @@ describe('PlatformGameForkSection', () => {
submitForm();
expect((await screen.findByRole('alert')).textContent).toBe(
'作者没有开放这个作品的共创授权,无法改编',
'作者没有开放这个作品的共创授权,无法 Fork',
);
expect(onProjectCreated).not.toHaveBeenCalled();
expect(
screen.getByRole('button', { name: '取回并创建项目' }),
screen.getByRole('button', { name: 'Fork 到我的项目' }),
).toHaveProperty('disabled', false);
});
it('409 作品不可用与 401 未登录:各自给出对应提示且不建项目', async () => {
let failure = new Error(
'fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
'fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
);
installInvoke(() => {
throw failure;
@@ -188,7 +188,7 @@ describe('PlatformGameForkSection', () => {
typeGameInput('game_private');
submitForm();
expect((await screen.findByRole('alert')).textContent).toContain(
'该作品当前不能作为改编来源',
'该作品当前不能被 Fork',
);
failure = new Error(
@@ -218,8 +218,8 @@ describe('PlatformGameForkSection', () => {
typeGameInput('game_parent_1');
submitForm();
expect(await screen.findByText(/正在取回作品成品/u)).not.toBeNull();
expect(screen.getByRole('button', { name: '正在取回…' })).toHaveProperty(
expect(await screen.findByText(/正在 Fork:下载/u)).not.toBeNull();
expect(screen.getByRole('button', { name: '正在 Fork…' })).toHaveProperty(
'disabled',
true,
);
@@ -229,11 +229,11 @@ describe('PlatformGameForkSection', () => {
resolveFork(PROJECT_RESULT);
await waitFor(() =>
expect(screen.queryByText(/正在取回作品成品/u)).toBeNull(),
expect(screen.queryByText(/正在 Fork/u)).toBeNull(),
);
});
it('成功:用解析出的作品 ID 调命令、进入新项目并展示「改编自」事实', async () => {
it('成功:用解析出的作品 ID 调命令、进入新项目并展示 Fork 来源', async () => {
const calls = installInvoke((command) =>
command === FORK_COMMAND ? PROJECT_RESULT : null,
);
@@ -258,10 +258,10 @@ describe('PlatformGameForkSection', () => {
.getAllByRole('status')
.map((node) => node.textContent ?? '')
.join('\n');
expect(statusText).toContain('改编自平台作品 game_parent_1');
expect(statusText).toContain('reference/game_parent_1/');
// 两种取件形态都要说清:工程源包直接就是可编辑工程,成品包只作可玩参考。
expect(statusText).toContain('可编辑工程');
expect(statusText).toContain('可玩参考');
expect(statusText).toContain('Fork 自平台作品 game_parent_1');
// 来源记录事实保留;对外口径不再展开「参考副本 / 能不能再发布」那套解释。
expect(statusText).toContain('.agent/fork-source.json');
expect(statusText).not.toContain('可玩参考');
expect(statusText).not.toContain('不能直接重新发布');
});
});