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
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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+7
-12
@@ -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/<作品ID>/</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('不能直接重新发布');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user