feat(游戏共创): 取件按 source 分支建项,来源记录升到 v2(兼容 v1)
- 来源记录 v2:新增 `source`(`package` / `project`),当前写入 `agc-fork-source.v2`;
解析同时接受 v1——v1 没有该字段,按「成品包」解释(当时只有这一条取件通道),
因此老项目仍能正确读出「改编自哪个作品」,发布时照常带上声明。未知版本与非法取值仍按
「没有来源记录」失败关闭。
- 建项按取件形态分支(`create_project_from_platform_fork_at` 增加 `source` 入参,远程
`source` 取值在命令层映射):
· `project`(工程源包):先把包解到**项目根**再走标准初始化。源包自带
`package.json` / `vite.config.*`,初始化会跳过 npm 脚手架判据,只补 `.agent` 身份、
目录与 agent.db,得到一份能继续改、能重新构建的工程;不再生成 `reference/` 参考副本。
· `package`(成品包):维持原形态(合规脚手架 + `reference/<gameId>/` 可玩参考)。
- 解压仍走既有门禁(`extract_template_archive`:条目数/单文件上限、拒符号链接、条目路径
只允许项目内相对路径;ZIP 根 == 项目根与模板包同一合同)。
- 命令文档同步说明两种形态的差别(工程源包=解压即工程;成品包=可玩参考,不能直接再发布)。
测试:新增「工程源包建项得到可编辑工程」用例(源码逐字节不被脚手架覆盖、`.agent` 身份已补、
无 `reference/`、记录 source=project)与「v1 记录仍可读且按成品包解释」用例;
既有成品包建项用例补断言 source=package。
This commit is contained in:
@@ -66,14 +66,22 @@ pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, Str
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
/// 用平台作品的成品包建一个新项目。
|
||||
/// 用平台作品的取件内容建一个新项目。
|
||||
///
|
||||
/// 先按标准初始化生成合规脚手架,再把参考副本铺进 `<project>/reference/<gameId>/`,
|
||||
/// 最后补齐来源记录;任一步失败都删掉半成品目录,不留无法解释的项目。
|
||||
/// 两种取件形态(`source`)落法不同:
|
||||
/// - `Package`(已构建成品包):先按标准初始化生成合规脚手架,再把参考副本铺进
|
||||
/// `<project>/reference/<gameId>/`——成品包没有源码,只能当「可玩参考 + 素材」。
|
||||
/// - `Project`(工程源包):包本身就是可编辑工程(自带 `package.json` / `vite.config.*`),
|
||||
/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/package.json` 已存在),
|
||||
/// 只补 `.agent` 身份、目录与 agent.db,得到一份能继续改、能重新构建的工程(方案 §3.5.2)。
|
||||
///
|
||||
/// 任一步失败都删掉半成品目录,不留无法解释的项目。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn create_project_from_platform_fork_at(
|
||||
projects_root: &Path,
|
||||
game_id: &str,
|
||||
version_id: &str,
|
||||
source: ProjectForkSourceKind,
|
||||
package_bytes: &[u8],
|
||||
requested_name: Option<&str>,
|
||||
planning: bool,
|
||||
@@ -116,27 +124,60 @@ pub(crate) fn create_project_from_platform_fork_at(
|
||||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
// 脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被
|
||||
// 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。
|
||||
let project = init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
)?;
|
||||
let reference_root = project_root.join(&reference_relative);
|
||||
ensure_game_creator_private_directory_tree(&reference_root, "改编参考目录")?;
|
||||
// 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、
|
||||
// 条目路径只允许项目内相对路径。
|
||||
crate::template_library::extract_template_archive(
|
||||
package_bytes,
|
||||
&reference_root,
|
||||
)
|
||||
.map_err(|error| format!("改编来源发行包解压失败:{error}"))?;
|
||||
write_project_fork_source(
|
||||
&project_root,
|
||||
&ProjectForkSourceRecord::new(game_id, version_id),
|
||||
)?;
|
||||
Ok(project)
|
||||
match source {
|
||||
// 工程源包:包本身就是可编辑工程,先解到项目根再初始化。
|
||||
ProjectForkSourceKind::Project => {
|
||||
// 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、
|
||||
// 条目路径只允许项目内相对路径(ZIP 根 == 项目根)。
|
||||
crate::template_library::extract_template_archive(
|
||||
package_bytes,
|
||||
&project_root,
|
||||
)
|
||||
.map_err(|error| format!("改编来源工程源包解压失败:{error}"))?;
|
||||
let project = init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
)?;
|
||||
write_project_fork_source(
|
||||
&project_root,
|
||||
&ProjectForkSourceRecord::new(
|
||||
game_id,
|
||||
version_id,
|
||||
ProjectForkSourceKind::Project,
|
||||
),
|
||||
)?;
|
||||
Ok(project)
|
||||
}
|
||||
// 成品包:脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被
|
||||
// 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。
|
||||
ProjectForkSourceKind::Package => {
|
||||
let project = init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
)?;
|
||||
let reference_root = project_root.join(&reference_relative);
|
||||
ensure_game_creator_private_directory_tree(
|
||||
&reference_root,
|
||||
"改编参考目录",
|
||||
)?;
|
||||
crate::template_library::extract_template_archive(
|
||||
package_bytes,
|
||||
&reference_root,
|
||||
)
|
||||
.map_err(|error| format!("改编来源发行包解压失败:{error}"))?;
|
||||
write_project_fork_source(
|
||||
&project_root,
|
||||
&ProjectForkSourceRecord::new(
|
||||
game_id,
|
||||
version_id,
|
||||
ProjectForkSourceKind::Package,
|
||||
),
|
||||
)?;
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&project_root);
|
||||
@@ -228,6 +269,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 最小工程源包:自带 `game/package.json` 与 `game/index.html`(这正是「解压即工程」的条件)。
|
||||
fn project_bundle_bytes() -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let options = SimpleFileOptions::default();
|
||||
for (name, content) in [
|
||||
("game/index.html", "<html>source entry</html>"),
|
||||
(
|
||||
"game/package.json",
|
||||
"{\"dependencies\":{\"phaser\":\"4.2.1\"}}",
|
||||
),
|
||||
("game/vite.config.js", "export default {};"),
|
||||
("game/src/game.js", "console.log('from bundle');"),
|
||||
] {
|
||||
writer.start_file(name, options).expect("start entry");
|
||||
std::io::Write::write_all(&mut writer, content.as_bytes()).expect("write entry");
|
||||
}
|
||||
writer.finish().expect("finish zip").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_project_bundle_source_yields_an_editable_project_without_reference_copy() {
|
||||
let projects_root = test_root("create-project-source");
|
||||
let bytes = project_bundle_bytes();
|
||||
let project = create_project_from_platform_fork_at(
|
||||
&projects_root,
|
||||
"game_upstream",
|
||||
"gamever_upstream",
|
||||
ProjectForkSourceKind::Project,
|
||||
&bytes,
|
||||
Some("源码改编测试"),
|
||||
false,
|
||||
)
|
||||
.expect("create fork project from project bundle");
|
||||
let root = Path::new(&project.project_path);
|
||||
|
||||
// 1) 源包内容逐字节落地:工程源码不被脚手架覆盖(package.json 必须是包里的那份)。
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/package.json")).unwrap(),
|
||||
"{\"dependencies\":{\"phaser\":\"4.2.1\"}}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/src/game.js")).unwrap(),
|
||||
"console.log('from bundle');"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/index.html")).unwrap(),
|
||||
"<html>source entry</html>"
|
||||
);
|
||||
|
||||
// 2) 标准初始化仍然跑过:补上项目身份与 agent.db,工程才能被 AGC 正常打开。
|
||||
assert!(root.join(".agent/manifest.json").is_file());
|
||||
assert!(root.join(".agent/agent.db").is_file());
|
||||
|
||||
// 3) 工程源包不做参考副本:包里就是工程本身。
|
||||
assert!(!root.join("reference").exists());
|
||||
|
||||
// 4) 来源记录带上取件形态(v2)。
|
||||
let record = read_project_fork_source(root).expect("fork source record");
|
||||
assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION);
|
||||
assert_eq!(record.source, ProjectForkSourceKind::Project);
|
||||
assert_eq!(record.game_id, "game_upstream");
|
||||
assert_eq!(record.version_id, "gamever_upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_fork_project_gets_scaffold_reference_copy_and_source_record() {
|
||||
let projects_root = test_root("create");
|
||||
@@ -236,6 +341,7 @@ mod tests {
|
||||
&projects_root,
|
||||
"game_parent",
|
||||
"gamever_parent",
|
||||
ProjectForkSourceKind::Package,
|
||||
&bytes,
|
||||
Some("改编测试"),
|
||||
false,
|
||||
@@ -257,8 +363,9 @@ mod tests {
|
||||
fs::read_to_string(root.join("game/index.html")).unwrap()
|
||||
);
|
||||
|
||||
// 3) 来源记录与项目身份一致。
|
||||
// 3) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。
|
||||
let record = read_project_fork_source(root).expect("fork source record");
|
||||
assert_eq!(record.source, ProjectForkSourceKind::Package);
|
||||
assert_eq!(record.game_id, "game_parent");
|
||||
assert_eq!(record.version_id, "gamever_parent");
|
||||
assert_eq!(project.manifest.name, "改编测试");
|
||||
@@ -272,6 +379,7 @@ mod tests {
|
||||
&projects_root,
|
||||
"game_parent",
|
||||
"gamever_parent",
|
||||
ProjectForkSourceKind::Package,
|
||||
b"not-a-zip",
|
||||
None,
|
||||
false,
|
||||
@@ -299,6 +407,7 @@ mod tests {
|
||||
&projects_root,
|
||||
"../escape",
|
||||
"gamever_parent",
|
||||
ProjectForkSourceKind::Package,
|
||||
&bytes,
|
||||
None,
|
||||
false,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// 桌面 Fork 取件接入;摘要校验、参考副本落点与来源记录规则保留在父模块供现有测试验证。
|
||||
use super::*;
|
||||
use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session};
|
||||
use shared_contracts::game_distribution::GameDistributionForkSourceKind;
|
||||
|
||||
/// 从平台作品开始创作:取平台已授权公开作品的发行成品包,建成「合规脚手架 + 参考副本」项目。
|
||||
/// 从平台作品开始创作:按取件形态建成项目。
|
||||
///
|
||||
/// - `source: "project"`(工程源包):解压即得可编辑工程,再走标准初始化补 `.agent` 身份。
|
||||
/// - `source: "package"`(成品包):生成合规 Phaser 4 + Vite 脚手架,成品包作可玩参考放进
|
||||
/// `reference/<gameId>/`——它没有源码,不能直接再次发布。
|
||||
///
|
||||
/// 失败关闭点(按顺序):
|
||||
/// 1. 没有平台登录态 → 直接给出可操作的登录提示,不发起任何请求;
|
||||
@@ -33,6 +38,11 @@ pub(crate) async fn create_local_project_from_platform_game(
|
||||
&projects_root,
|
||||
&source.game_id,
|
||||
&source.version_id,
|
||||
match source.source {
|
||||
// 取件形态决定建项方式:工程源包解压即工程,成品包只作可玩参考。
|
||||
GameDistributionForkSourceKind::Project => ProjectForkSourceKind::Project,
|
||||
GameDistributionForkSourceKind::Package => ProjectForkSourceKind::Package,
|
||||
},
|
||||
&package_bytes,
|
||||
name.as_deref(),
|
||||
planning.unwrap_or(false),
|
||||
|
||||
@@ -14,8 +14,24 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 记录格式版本。字段形状变化时递增,旧版本按「没有来源记录」处理(失败关闭到不声明来源)。
|
||||
pub(crate) const FORK_SOURCE_SCHEMA_VERSION: &str = "agc-fork-source.v1";
|
||||
/// 当前写入的记录格式版本。
|
||||
///
|
||||
/// v2 起多带 `source`(取件形态),v1 记录**仍必须能读**:老项目里已有 v1 文件,
|
||||
/// 读不回来就等于把「改编自哪个作品」这条事实丢了。
|
||||
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 侧只能「合规脚手架 + 参考副本」。
|
||||
#[default]
|
||||
Package,
|
||||
/// 工程源包:解压即得可编辑工程,不需要再生成脚手架。
|
||||
Project,
|
||||
}
|
||||
|
||||
/// 相对项目根的位置:`.agent` 是项目身份与 Agent 状态的权威目录,整目录随项目同步。
|
||||
pub(crate) const FORK_SOURCE_RELATIVE_PATH: &str = ".agent/fork-source.json";
|
||||
@@ -24,25 +40,30 @@ const FORK_SOURCE_LABEL: &str = "项目改编来源记录";
|
||||
|
||||
/// 「本项目改编自平台作品」的本地记录。
|
||||
///
|
||||
/// `schema_version` 必填且必须等于当前版本;`game_id` / `version_id` 是平台侧的作品身份与
|
||||
/// 建立血缘时锁定的父版本,发布时原样并入创建作品请求的 `fork` 字段。
|
||||
/// `schema_version` 必填;`game_id` / `version_id` 是平台侧的作品身份与建立血缘时锁定的父版本,
|
||||
/// 发布时原样并入创建作品请求的 `fork` 字段。`source` 从 v2 起记录取件形态;v1 记录没有该字段,
|
||||
/// 读回来按「成品包」(当时只有这一条通道),因此老项目仍能正确声明改编来源。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ProjectForkSourceRecord {
|
||||
pub(crate) schema_version: String,
|
||||
pub(crate) game_id: String,
|
||||
pub(crate) version_id: String,
|
||||
/// 取件形态;v1 记录缺省 → 成品包。
|
||||
#[serde(default)]
|
||||
pub(crate) source: ProjectForkSourceKind,
|
||||
/// 取件成功并铺好参考副本的时刻(epoch 毫秒),只作本地审计,不参与任何上传。
|
||||
pub(crate) adopted_at_millis: u64,
|
||||
}
|
||||
|
||||
impl ProjectForkSourceRecord {
|
||||
/// 新建一条来源记录;时间戳由本模块统一生成,调用方不自己造时间。
|
||||
pub(crate) fn new(game_id: &str, version_id: &str) -> Self {
|
||||
pub(crate) fn new(game_id: &str, version_id: &str, source: ProjectForkSourceKind) -> Self {
|
||||
Self {
|
||||
schema_version: FORK_SOURCE_SCHEMA_VERSION.to_string(),
|
||||
game_id: game_id.to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
source,
|
||||
adopted_at_millis: now_millis(),
|
||||
}
|
||||
}
|
||||
@@ -60,12 +81,15 @@ pub(crate) fn fork_source_record_path(project_root: &Path) -> PathBuf {
|
||||
project_root.join(FORK_SOURCE_RELATIVE_PATH)
|
||||
}
|
||||
|
||||
/// 解析来源记录正文:格式版本不符、字段缺失或标识为空都按「没有来源记录」处理。
|
||||
/// 解析来源记录正文:格式版本不认识、字段缺失或标识为空都按「没有来源记录」处理。
|
||||
///
|
||||
/// 抽成纯函数是为了让「损坏不 panic」这条规则可以被单测钉住,而不是散在读取路径里。
|
||||
pub(crate) fn parse_project_fork_source(bytes: &[u8]) -> Option<ProjectForkSourceRecord> {
|
||||
let record: ProjectForkSourceRecord = serde_json::from_slice(bytes).ok()?;
|
||||
if record.schema_version != FORK_SOURCE_SCHEMA_VERSION {
|
||||
// v1 与 v2 都接受:v1 缺 `source`,serde 默认填成品包(见结构体注释)。
|
||||
if record.schema_version != FORK_SOURCE_SCHEMA_VERSION
|
||||
&& record.schema_version != FORK_SOURCE_SCHEMA_VERSION_V1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if record.game_id.trim().is_empty() || record.version_id.trim().is_empty() {
|
||||
@@ -119,7 +143,11 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_source_record_round_trips_through_project_agent_directory() {
|
||||
let root = test_root("round-trip");
|
||||
let record = ProjectForkSourceRecord::new("game_parent_1", "gamever_parent_1");
|
||||
let record = ProjectForkSourceRecord::new(
|
||||
"game_parent_1",
|
||||
"gamever_parent_1",
|
||||
ProjectForkSourceKind::Project,
|
||||
);
|
||||
write_project_fork_source(&root, &record).expect("write fork source record");
|
||||
|
||||
// 落点固定:`.agent/fork-source.json`,不落在项目根,也不进 manifest。
|
||||
@@ -131,16 +159,42 @@ mod tests {
|
||||
let read = read_project_fork_source(&root).expect("read back");
|
||||
assert_eq!(read, record);
|
||||
assert_eq!(read.schema_version, FORK_SOURCE_SCHEMA_VERSION);
|
||||
assert_eq!(read.source, ProjectForkSourceKind::Project);
|
||||
assert_eq!(read.game_id, "game_parent_1");
|
||||
assert_eq!(read.version_id, "gamever_parent_1");
|
||||
assert!(read.adopted_at_millis > 0);
|
||||
|
||||
// 覆盖写:同一路径第二次写入必须替换而不是失败。
|
||||
let updated = ProjectForkSourceRecord::new("game_parent_2", "gamever_parent_2");
|
||||
let updated = ProjectForkSourceRecord::new(
|
||||
"game_parent_2",
|
||||
"gamever_parent_2",
|
||||
ProjectForkSourceKind::Package,
|
||||
);
|
||||
write_project_fork_source(&root, &updated).expect("overwrite fork source record");
|
||||
assert_eq!(read_project_fork_source(&root), Some(updated));
|
||||
}
|
||||
|
||||
/// v1 记录(M2a 写的,没有 `source`)必须仍能读:否则老项目会丢掉「改编自哪个作品」,
|
||||
/// 发布时静默不带声明。
|
||||
#[test]
|
||||
fn v1_fork_source_record_still_reads_as_package_source() {
|
||||
let root = test_root("v1-compat");
|
||||
let path = fork_source_record_path(&root);
|
||||
write_game_creator_private_file(
|
||||
&path,
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_legacy","versionId":"gamever_legacy","adoptedAtMillis":123}"#,
|
||||
FORK_SOURCE_LABEL,
|
||||
)
|
||||
.expect("write v1 record");
|
||||
|
||||
let record = read_project_fork_source(&root).expect("v1 记录必须仍可读");
|
||||
assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION_V1);
|
||||
assert_eq!(record.game_id, "game_legacy");
|
||||
assert_eq!(record.version_id, "gamever_legacy");
|
||||
// v1 时代只有成品包通道,缺省按成品包解释。
|
||||
assert_eq!(record.source, ProjectForkSourceKind::Package);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fork_source_record_reads_as_none() {
|
||||
let root = test_root("missing");
|
||||
@@ -162,8 +216,10 @@ mod tests {
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1"}"#.as_slice(),
|
||||
// 未知顶层字段(deny_unknown_fields)
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1","versionId":"v1","adoptedAtMillis":1,"extra":true}"#.as_slice(),
|
||||
// 格式版本不是当前版本
|
||||
br#"{"schemaVersion":"agc-fork-source.v2","gameId":"game_1","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 完全不认识的格式版本(v1/v2 之外的都按「没有记录」)
|
||||
br#"{"schemaVersion":"agc-fork-source.v9","gameId":"game_1","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 已知版本但 source 取值非法
|
||||
br#"{"schemaVersion":"agc-fork-source.v2","gameId":"game_1","versionId":"v1","source":"remix","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 标识为空
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":" ","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 空文件
|
||||
|
||||
Reference in New Issue
Block a user