Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ebc3a143 | |||
| 81419a84bd | |||
| b6847a3232 | |||
| 35c0859ae8 | |||
| 89a901e329 | |||
| 46024b5043 | |||
| 8484a30d49 | |||
| c6a0844b50 | |||
| 5c648ab9c7 | |||
| c7ac4f3bab | |||
| 155f0d316a | |||
| 6ed1fd26ed | |||
| 4912aa4df0 |
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
|
||||
@@ -145,15 +145,6 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
|
||||
ELEVENLABS_API_KEY=""
|
||||
ELEVENLABS_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。
|
||||
# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all)
|
||||
# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。
|
||||
TRIPO_BASE_URL="https://openapi.tripo3d.com/v3"
|
||||
TRIPO_API_KEY=""
|
||||
# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。
|
||||
TRIPO_REQUEST_TIMEOUT_MS="60000"
|
||||
TRIPO_RETRIES="2"
|
||||
|
||||
# 阿里云 OSS 配置。
|
||||
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
|
||||
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
|
||||
|
||||
-84
@@ -67,90 +67,6 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛
|
||||
|
||||
## Language
|
||||
|
||||
### 3D Model Generation
|
||||
|
||||
**3D 模型生成操作**:
|
||||
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
|
||||
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
|
||||
|
||||
**Provider task**:
|
||||
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
|
||||
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
|
||||
|
||||
**临时 provider URL**:
|
||||
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
|
||||
_Avoid_: 永久 OSS URL、资源 ID
|
||||
|
||||
**模型 artifact**:
|
||||
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
|
||||
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
|
||||
|
||||
**Tripo API facade**:
|
||||
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
|
||||
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
|
||||
|
||||
**Tripo operation**:
|
||||
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
|
||||
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
|
||||
|
||||
**模型产物持久化**:
|
||||
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
|
||||
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
|
||||
|
||||
**3D provider 配置门禁**:
|
||||
Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。
|
||||
_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现
|
||||
|
||||
**3D 生成底价**:
|
||||
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
|
||||
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
|
||||
|
||||
**3D 生成加价项**:
|
||||
叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。
|
||||
_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价
|
||||
|
||||
**定价配置归一化**:
|
||||
读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。
|
||||
_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价
|
||||
|
||||
**模型生成落点**:
|
||||
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
|
||||
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
|
||||
|
||||
**模型输入资源引用**:
|
||||
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
|
||||
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
|
||||
|
||||
**3D 模型生成工具**:
|
||||
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。
|
||||
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
|
||||
|
||||
**3D 生成定价**:
|
||||
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。
|
||||
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布
|
||||
|
||||
**定价版本(乐观锁)**:
|
||||
后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。
|
||||
_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」
|
||||
|
||||
### 3D 模型资源预览
|
||||
|
||||
**3D 模型资源**:
|
||||
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
|
||||
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
|
||||
|
||||
**模型预览图**:
|
||||
3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
|
||||
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
|
||||
|
||||
**模型本体**:
|
||||
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
|
||||
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
|
||||
|
||||
**3D 查看器预览**:
|
||||
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
|
||||
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
|
||||
|
||||
### Puzzle Clear
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -60,7 +60,6 @@ import type {
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -520,7 +519,7 @@ export function getAdminEditorGenerationPricing(token: string) {
|
||||
|
||||
export function upsertAdminEditorGenerationPricing(
|
||||
token: string,
|
||||
payload: AdminUpsertEditorGenerationPricingRequest,
|
||||
payload: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
return request<EditorGenerationPricingConfigPayload>(
|
||||
'/admin/api/editor-generation-pricing',
|
||||
|
||||
@@ -431,43 +431,8 @@ export interface EditorGenerationModelPricingPayload {
|
||||
prices?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */
|
||||
export interface EditorGenerationModel3dVersionPricePayload {
|
||||
noTexture: number;
|
||||
texture: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。
|
||||
*
|
||||
* 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回,
|
||||
* 不在前端硬编码清单,也不能新增、删除或停用。
|
||||
*/
|
||||
export interface EditorGenerationModel3dEndpointPricingPayload {
|
||||
versionPrices: Record<string, EditorGenerationModel3dVersionPricePayload>;
|
||||
addOnPrices: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 3D 生成定价段:两个端点并列,各持自己的设置。 */
|
||||
export interface EditorGenerationModel3dPricingPayload {
|
||||
textToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
}
|
||||
|
||||
export interface EditorGenerationPricingConfigPayload {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
|
||||
model3d?: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
|
||||
*
|
||||
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
|
||||
*/
|
||||
export interface AdminUpsertEditorGenerationPricingRequest {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
model3d: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModel3dVersionPricePayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
EDITOR_MODEL3D_ENDPOINT_SECTIONS,
|
||||
editorModel3dAddOnLabel,
|
||||
updateEditorModel3dEndpointPricing,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
/**
|
||||
* 一个模型版本的两个底价字段。两栏只有字段键与展示名不同,用描述表驱动渲染,
|
||||
* 避免两段近乎相同的 JSX 各自演化。
|
||||
*/
|
||||
const VERSION_PRICE_FIELDS = [
|
||||
{ key: 'noTexture', label: '无贴图' },
|
||||
{ key: 'texture', label: '带贴图' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dVersionPricePayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
interface AdminEditorGenerationModel3dPricingSectionProps {
|
||||
model3d: EditorGenerationModel3dPricingPayload;
|
||||
onChange: (next: EditorGenerationModel3dPricingPayload) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的
|
||||
* 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。
|
||||
*
|
||||
* 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。
|
||||
*/
|
||||
export function AdminEditorGenerationModel3dPricingSection({
|
||||
model3d,
|
||||
onChange,
|
||||
}: AdminEditorGenerationModel3dPricingSectionProps) {
|
||||
function updateEndpoint(
|
||||
section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'],
|
||||
updater: Parameters<typeof updateEditorModel3dEndpointPricing>[2],
|
||||
) {
|
||||
onChange(updateEditorModel3dEndpointPricing(model3d, section, updater));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-pricing-model-card">
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>3D 生成定价</strong>
|
||||
<span className="admin-pricing-unit">按次</span>
|
||||
</div>
|
||||
<p className="admin-pricing-model3d-hint">
|
||||
模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D
|
||||
生成不可提交。
|
||||
</p>
|
||||
<div className="admin-pricing-grid">
|
||||
{EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => (
|
||||
<section className="admin-panel admin-pricing-model-card" key={key}>
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>{label}</strong>
|
||||
</div>
|
||||
<div className="admin-pricing-model3d-list">
|
||||
{Object.entries(model3d[key].versionPrices).map(
|
||||
([version, price]) => (
|
||||
<div className="admin-pricing-model3d-row" key={version}>
|
||||
<span className="admin-pricing-model3d-row-label">
|
||||
{version}
|
||||
</span>
|
||||
{VERSION_PRICE_FIELDS.map(
|
||||
({ key: field, label: fieldLabel }) => (
|
||||
<label className="admin-field" key={field}>
|
||||
<span>{fieldLabel}</span>
|
||||
<input
|
||||
aria-label={`${label} ${version} ${fieldLabel}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price[field]}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
versionPrices: {
|
||||
...current.versionPrices,
|
||||
[version]: {
|
||||
...price,
|
||||
[field]: parsePositiveInteger(
|
||||
event.target.value,
|
||||
),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
{Object.entries(model3d[key].addOnPrices).map(
|
||||
([addOn, price]) => (
|
||||
<label className="admin-field" key={addOn}>
|
||||
<span>{editorModel3dAddOnLabel(addOn)}</span>
|
||||
<input
|
||||
aria-label={`${label} ${editorModel3dAddOnLabel(addOn)}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
addOnPrices: {
|
||||
...current.addOnPrices,
|
||||
[addOn]: parsePositiveInteger(event.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import {
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/adminApiClient')>(
|
||||
'../api/adminApiClient',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
const pricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {
|
||||
@@ -43,78 +41,36 @@ const pricing: EditorGenerationPricingConfigPayload = {
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
'chirp-v5': { unit: 'perGeneration', price: 12 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 8, texture: 16 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 16, texture: 24 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function savedPricing(
|
||||
overrides: Partial<EditorGenerationPricingConfigPayload> = {},
|
||||
) {
|
||||
return {
|
||||
...pricing,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue(
|
||||
savedPricing({
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('3D 生成定价');
|
||||
}
|
||||
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), {
|
||||
target: { value: '58' },
|
||||
});
|
||||
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
|
||||
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
@@ -134,82 +90,3 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByText('文生 3D')).toBeTruthy();
|
||||
expect(screen.getByText('图生 3D')).toBeTruthy();
|
||||
expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), {
|
||||
target: { value: '18' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), {
|
||||
target: { value: '86' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '6' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
model3d: {
|
||||
textToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'v3.1-20260211': { noTexture: 8, texture: 18 },
|
||||
}),
|
||||
addOnPrices: expect.objectContaining({ quadMesh: 6 }),
|
||||
}),
|
||||
imageToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'P2-20260801': { noTexture: 86, texture: 88 },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('数值小于 1 时禁用保存并给出中文提示', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '0' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy();
|
||||
const saveButton = screen.getByRole('button', {
|
||||
name: '保存定价',
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
await user.click(saveButton);
|
||||
expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后端未配置 3D 段时给出提示并禁止保存', async () => {
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
model3d: null,
|
||||
});
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,18 +6,12 @@ import {
|
||||
upsertAdminEditorGenerationPricing,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModelPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
EditorGenerationPricingUnitPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminEditorGenerationPricingPageProps {
|
||||
token: string;
|
||||
@@ -26,7 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
|
||||
|
||||
const emptyPricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {},
|
||||
model3d: null,
|
||||
};
|
||||
|
||||
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
|
||||
@@ -64,7 +57,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving || !canSave) {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage('');
|
||||
@@ -78,12 +71,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setPricing(
|
||||
await upsertAdminEditorGenerationPricing(
|
||||
token,
|
||||
buildAdminEditorGenerationPricingRequest(pricing),
|
||||
),
|
||||
);
|
||||
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -132,15 +120,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
});
|
||||
}
|
||||
|
||||
function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) {
|
||||
setPricing((current) => ({ ...current, model3d: next }));
|
||||
}
|
||||
|
||||
const invalidPriceLabels =
|
||||
collectInvalidEditorGenerationPricingLabels(pricing);
|
||||
const isModel3dMissing = !pricing.model3d;
|
||||
const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing;
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -165,20 +144,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isModel3dMissing ? (
|
||||
<div className="admin-alert" role="status">
|
||||
当前部署未配置 3D 定价段(缺失即 3D
|
||||
生成不可提交):需先由受控默认配置提供两段价格,
|
||||
后台不在页面里新建或停用整段。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invalidPriceLabels.length > 0 ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="admin-stack" onSubmit={handleSave}>
|
||||
<div className="admin-pricing-model-list admin-pricing-model-list--single">
|
||||
{Object.entries(pricing.models).map(([model, modelPricing]) =>
|
||||
@@ -191,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pricing.model3d ? (
|
||||
<AdminEditorGenerationModel3dPricingSection
|
||||
model3d={pricing.model3d}
|
||||
onChange={updateModel3dPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving || !canSave}
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
@@ -265,3 +223,8 @@ function renderModelPricingCard({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '../api/adminApiClient';
|
||||
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProfileWalletConfigPageProps {
|
||||
token: string;
|
||||
@@ -197,3 +197,8 @@ export function AdminProfileWalletConfigPage({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ProfileRechargeProductKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminRechargeProductPageProps {
|
||||
token: string;
|
||||
@@ -546,6 +546,11 @@ function formatPrice(priceCents: number) {
|
||||
return `¥${(priceCents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ProfileRedeemCodeMode,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
interface AdminRedeemCodePageProps {
|
||||
token: string;
|
||||
@@ -422,6 +422,11 @@ export function AdminRedeemCodePage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function redeemModeLabel(value: ProfileRedeemCodeMode) {
|
||||
return redeemModes.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTaskConfigPageProps {
|
||||
token: string;
|
||||
@@ -571,6 +571,11 @@ export function AdminTaskConfigPage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
editorModel3dAddOnLabel,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
function pricingFixture(): EditorGenerationPricingConfigPayload {
|
||||
return {
|
||||
models: {
|
||||
'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 3, '2K': 5 } },
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 8, texture: 16 } },
|
||||
addOnPrices: { hdTexture: 8, unknownAddOn: 7 },
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 16, texture: 24 } },
|
||||
addOnPrices: { hdTexture: 8 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('数值解析把空串、非数字、小数与 0 收敛成 0', () => {
|
||||
expect(parsePositiveInteger('12')).toBe(12);
|
||||
expect(parsePositiveInteger('')).toBe(0);
|
||||
expect(parsePositiveInteger('abc')).toBe(0);
|
||||
expect(parsePositiveInteger('12abc')).toBe(0);
|
||||
expect(parsePositiveInteger('3.9')).toBe(0);
|
||||
expect(parsePositiveInteger('0')).toBe(0);
|
||||
expect(parsePositiveInteger('-3')).toBe(0);
|
||||
});
|
||||
|
||||
test('未登记的 3D 加价项键回退显示原键名', () => {
|
||||
expect(editorModel3dAddOnLabel('hdTexture')).toBe('高清贴图');
|
||||
expect(editorModel3dAddOnLabel('unknownAddOn')).toBe('unknownAddOn');
|
||||
});
|
||||
|
||||
test('校验覆盖模型档位与 3D 两段的全部数值', () => {
|
||||
expect(collectInvalidEditorGenerationPricingLabels(pricingFixture())).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const invalid = pricingFixture();
|
||||
const invalidImagePricing = invalid.models['gpt-image-2'];
|
||||
const invalidTextEndpoint = invalid.model3d?.textToModelPricing;
|
||||
const invalidImageEndpoint = invalid.model3d?.imageToModelPricing;
|
||||
if (
|
||||
!invalidImagePricing?.prices ||
|
||||
!invalidTextEndpoint ||
|
||||
!invalidImageEndpoint
|
||||
) {
|
||||
throw new Error('夹具必须包含图片档位与 3D 两段');
|
||||
}
|
||||
const imageVersionPrice = invalidImageEndpoint.versionPrices['v3.1-20260211'];
|
||||
if (!imageVersionPrice) {
|
||||
throw new Error('夹具必须包含图生 3D 的版本底价');
|
||||
}
|
||||
invalidImagePricing.prices['2K'] = 0;
|
||||
invalidTextEndpoint.addOnPrices.hdTexture = 0;
|
||||
imageVersionPrice.noTexture = 0;
|
||||
expect(collectInvalidEditorGenerationPricingLabels(invalid)).toEqual([
|
||||
'gpt-image-2 2K',
|
||||
'文生 3D 高清贴图',
|
||||
'图生 3D v3.1-20260211 无贴图',
|
||||
]);
|
||||
});
|
||||
|
||||
test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => {
|
||||
const pricing = pricingFixture();
|
||||
expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d,
|
||||
});
|
||||
expect(
|
||||
buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }),
|
||||
).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: null,
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type {
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
EditorGenerationModel3dEndpointPricingPayload,
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 3D 端点在两个并列段里的字段名与后台展示名。 */
|
||||
export const EDITOR_MODEL3D_ENDPOINT_SECTIONS = [
|
||||
{ key: 'textToModelPricing', label: '文生 3D' },
|
||||
{ key: 'imageToModelPricing', label: '图生 3D' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dPricingPayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 3D 加价项键到中文标签。键来自接口返回,这里只负责展示;遇到未登记的键回退显示原键名,
|
||||
* 不因为标签缺失就把该项漏掉。
|
||||
*/
|
||||
const EDITOR_MODEL3D_ADD_ON_LABELS: Record<string, string> = {
|
||||
hdTexture: '高清贴图',
|
||||
ultraTexture: '超高清贴图',
|
||||
hdGeometry: '高清几何',
|
||||
quadMesh: '四边形网格',
|
||||
smartLowPoly: '智能低面',
|
||||
generateParts: '分件生成',
|
||||
};
|
||||
|
||||
export function editorModel3dAddOnLabel(addOn: string) {
|
||||
return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn;
|
||||
}
|
||||
|
||||
export function isPositiveMudPoints(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前校验:所有泥点数值都必须是不小于 1 的整数,返回不合法项的展示名。
|
||||
*
|
||||
* 后端仍会独立校验一遍;这里只是不把明显非法的 payload 发出去,避免保存后才报错。
|
||||
*/
|
||||
export function collectInvalidEditorGenerationPricingLabels(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
const labels: string[] = [];
|
||||
for (const [model, modelPricing] of Object.entries(pricing.models)) {
|
||||
const tieredPrices = modelPricing.prices;
|
||||
// `prices: {}` 没有档位可校验,不能算「这一项走分层计价」——否则底价会被顺手跳过校验。
|
||||
if (tieredPrices && Object.keys(tieredPrices).length > 0) {
|
||||
for (const [tier, price] of Object.entries(tieredPrices)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${model} ${tier}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isPositiveMudPoints(modelPricing.price)) {
|
||||
labels.push(`${model} 泥点`);
|
||||
}
|
||||
}
|
||||
|
||||
const model3d = pricing.model3d;
|
||||
if (model3d) {
|
||||
for (const { key, label } of EDITOR_MODEL3D_ENDPOINT_SECTIONS) {
|
||||
// 段与子表都来自接口返回:缺段或缺子表时只跳过这一段,不能让整页校验崩掉。
|
||||
const endpointPricing = model3d[key];
|
||||
if (!endpointPricing) {
|
||||
continue;
|
||||
}
|
||||
for (const [version, price] of Object.entries(
|
||||
endpointPricing.versionPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price.noTexture)) {
|
||||
labels.push(`${label} ${version} 无贴图`);
|
||||
}
|
||||
if (!isPositiveMudPoints(price.texture)) {
|
||||
labels.push(`${label} ${version} 带贴图`);
|
||||
}
|
||||
}
|
||||
for (const [addOn, price] of Object.entries(
|
||||
endpointPricing.addOnPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${label} ${editorModel3dAddOnLabel(addOn)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。
|
||||
* 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。
|
||||
*/
|
||||
export function buildAdminEditorGenerationPricingRequest(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
): AdminUpsertEditorGenerationPricingRequest {
|
||||
return {
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */
|
||||
export function updateEditorModel3dEndpointPricing(
|
||||
model3d: EditorGenerationModel3dPricingPayload,
|
||||
section: keyof EditorGenerationModel3dPricingPayload,
|
||||
updater: (
|
||||
current: EditorGenerationModel3dEndpointPricingPayload,
|
||||
) => EditorGenerationModel3dEndpointPricingPayload,
|
||||
): EditorGenerationModel3dPricingPayload {
|
||||
return { ...model3d, [section]: updater(model3d[section]) };
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient';
|
||||
|
||||
/**
|
||||
* 泥点 / 积分类输入框文本转正整数:空串、非数字、小数、0 与负数一律收敛成 0,
|
||||
* 由各页的提交前校验拦住。
|
||||
*
|
||||
* 用 `Number` 而不是 `parseInt`:`parseInt` 会把 `3.9` 截成 3、`12abc` 截成 12,
|
||||
* 等于把编辑中的半截输入静默当成合法值存下去;再用 `isSafeInteger` 挡掉超出安全整数
|
||||
* 范围的值(那种值在别处会被静默改写)。后台各页统一用这一份,不再各写一遍。
|
||||
*/
|
||||
export function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function handlePageError(
|
||||
error: unknown,
|
||||
onUnauthorized: (message?: string) => void,
|
||||
|
||||
@@ -2972,34 +2972,6 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row-label {
|
||||
color: #5c3a26;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-hint {
|
||||
color: #7b5a45;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-pricing-unit {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
@@ -3050,7 +3022,6 @@ button:disabled {
|
||||
.admin-two-column-wide,
|
||||
.admin-accounts-layout,
|
||||
.admin-pricing-grid,
|
||||
.admin-pricing-model3d-row,
|
||||
.admin-form-row,
|
||||
.admin-filter-grid,
|
||||
.admin-table-query-grid,
|
||||
|
||||
+4
-3
@@ -6312,7 +6312,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros 12.0.1",
|
||||
@@ -6333,9 +6334,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
|
||||
@@ -29,10 +29,7 @@ tauri-build = { version = "2.6.2", features = [] }
|
||||
[dependencies]
|
||||
codex-patch-parser = { path = "vendor/codex-patch-parser" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`
|
||||
# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit;
|
||||
# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。
|
||||
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" }
|
||||
ts-rs = "12.0.1"
|
||||
typed_floats = { version = "1.0.7", features = ["serde"] }
|
||||
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
||||
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
@@ -69,7 +66,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
共享过程文件(如需维护,请使用这些相对路径):
|
||||
- project/analysis.md
|
||||
- project/决策台账.md
|
||||
- project/dialog.md
|
||||
- project/analysis.md:重要取舍的依据与当前结论。
|
||||
- project/决策台账.md:待处理事项与下一步,必要时引用相关文档。
|
||||
- project/dialog.md:仅在用户需要时记录对话摘要或交接信息。
|
||||
正式产物使用当前阶段指定的相对路径。
|
||||
五个策划阶段的审批:当你判断当前策划阶段必需产物已完成时,必须提交阶段审批。用户批准后进入下一阶段。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择适用字段,同类内容可以合并,复杂项目可以增加必要字段。表格和列表中的示例行按实际对象逐行扩展:
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`,简要介绍当前游戏。后续仅在核心体验、范围、平台等概览内容变化时更新,不复制完整决策清单。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择适用字段,同类内容可以合并,复杂项目可以增加必要字段。表格和列表中的示例行按实际对象逐行扩展:
|
||||
|
||||
# 速览卡:《游戏名》
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
当前阶段:顶层设计。明确玩家持续游玩的循环、资源流、节奏和系统范围。
|
||||
当前阶段:顶层设计。明确游玩过程、关键规则与反馈、版本范围和验证计划,为系统划分提供依据。
|
||||
|
||||
+6
-64
@@ -1,66 +1,8 @@
|
||||
# 决策台账:《星露谷物语》金样项目
|
||||
# 决策台账:《星露谷物语》示例项目
|
||||
|
||||
版本:v3 | 规则:台账放活队列——design 只放结论、分析只放论证、决定与开放问题住这里。编号连续不复用;被推翻的行标 overturned 挂新行,不删行。
|
||||
状态六态:`confirmed`(用户亲口/亲选)/ `auto_decided`(技术类代决,必带理由+推翻条件,用户一键可翻)/ `default_pending`(默认建议兜底,用户未点头)/ `prototype_pending`(待原型验证)/ `pending_user`(等用户拍板)/ `overturned`(被推翻,挂旧行编号)。
|
||||
仅列仍需跟进的事项和下一步。详细依据与采用的规则见相关分析和设计文档;事项完成后移出待办。
|
||||
|
||||
> 编号口径:D-01~D-13 与 templates/stardew-analysis.md 台账节选一致(D-04~D-06、D-08~D-10、D-12 原为"就地小权衡,直接登记未开条目",此处按登记口径展开);D-14 起为技术文档期新增,与 stardew-tdd-tech.md 开放问题回执互引。
|
||||
|
||||
## 当前待办(活队列)
|
||||
|
||||
### 等用户拍板(pending_user)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-14 | 体力与战斗共享单池 | TDD | user | 风险资源统一制造取舍(概念张力一);**暂按共享实现,改单拆只需改 S02 成本入口** | 战斗参与率实测过低(玩家回避矿井) | pending_user(暂按共享实现) |
|
||||
| D-15 | 背包格子制 vs 重量制 | TDD | user | 格子制直觉、重量制焦虑感与 T5"休闲不打卡"冲突;暂按格子制实现、存档预留 capacity_type 字段 | 格子管理成为主要负面反馈 | pending_user(B 级阻断存档结构,暂按格子制) |
|
||||
|
||||
### 待原型验证(prototype_pending)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-13b | 战斗判定窗口手感(前摇帧数/无敌帧 450ms 基准) | 系统 | user | 数值可定、手感不可纸面验证 | 原型显示节奏拖慢/玩家困惑 | prototype_pending(规则本体见 D-13 confirmed) |
|
||||
|
||||
### 默认建议兜底(default_pending)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-19 | 天气权重表具体数值(晴/雨/风暴按季节) | TDD | agent | 概念层只定"雨免浇水"定性;数值推内容期填 | 前 5 日出现连续 3 日雨/全无雨 | default_pending(默认值已进数据表,带 designer_note) |
|
||||
|
||||
## 已采用决定
|
||||
|
||||
### 用户确认(confirmed)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-01 | 定调:牧场物语系参照、治愈慢节奏 | 概念 | user | 用户原始需求 | — | confirmed |
|
||||
| D-02 | 单人体验,无多人 | 概念 | user | 概念层非目标 | — | confirmed |
|
||||
| D-03 | 战斗保持伴生风险,不做装备驱动主轴 | 概念 | user | 概念期问题一 | 矿井流失率过半且归因战斗 | confirmed |
|
||||
| D-07 | 日目标自设,季节与社区提供低频牵引 | 顶层 | user | 顶层期问题一 | 新手周流失归因无方向 | confirmed |
|
||||
| D-11 | 采集/钓鱼/战斗统一"活动结果"接口 | 架构 | user | 架构期问题一 | 第三活动类型出现结构性差异 | confirmed |
|
||||
| D-13 | 战斗采用节奏/指令判定 | 系统 | user | S06 问题一 | 原型显示节奏拖慢/玩家困惑 | confirmed(手感部分拆 D-13b prototype_pending) |
|
||||
|
||||
### 技术代决(auto_decided——带理由与推翻条件,用户一键可翻)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据(理由) | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-04 | 时间片制:700ms=10 游戏分钟 | 概念 | agent | 原作实证节拍;一天≈14 分钟真实时间贴合 T5"休闲" | 内测一天体感过短/过长 | auto_decided |
|
||||
| D-05 | 分区域切换(区域独立场景,非连续地图) | 概念 | agent | 概念层"不是什么:无边界开放世界";区域小网络全部步行可达 | 场景切换成为移动负担反馈 | auto_decided |
|
||||
| D-06 | 28 日/季、四季/年 | 顶层 | agent | 季节窗口制造"本季计划"节奏(支柱二) | 换季频率在测试中被无视 | auto_decided |
|
||||
| D-09 | 商店营业时段走条件表 | 架构 | agent | 与配方/区域解锁共用 check(condition_id) 单一入口 | 条件表规模膨胀难维护 | auto_decided |
|
||||
| D-10 | 出货箱日终统一结算 | 架构 | agent | 收入集中进日终面板,强化"一天一结算"叙事;商店现卖保留即时通道 | 玩家普遍绕开出货箱 | auto_decided |
|
||||
| D-12 | 工具升级期间该工具不可用 | 系统 | agent | 升级=时间成本换效率(顶层张力二);备用旧工具暂不做(开放问题) | 升级期挫败感集中爆发 | auto_decided |
|
||||
| D-16 | 矿井逐层生成本期不做(P2) | TDD | agent | GDD 已标"不做无限地牢";首期按布局池 8~12 模板拼装 | 内测要求深度爬塔玩法 | auto_decided |
|
||||
| D-17 | 换装首期 5 层(基础体/裤/衣/发型/饰件),非 19 层 | TDD | agent | 外观自定义非首期卖点;层结构预留到 19 层 | 外观系统成核心诉求 | auto_decided |
|
||||
| D-18 | 作物品质三档:普通/银/金 | TDD | agent | 经济分层需要(即时变现 vs 等待升值的取舍) | 银金档无人区分、一律普通出售 | auto_decided |
|
||||
|
||||
### 已推翻(overturned——旧行保留,挂新行)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-08 | 作物品质两档:普通/银 | TDD | agent | 早期小权衡:两档最简 | — | **overturned → D-18**(经济分层不足,改三档;铱档留 P1) |
|
||||
|
||||
## 队列纪律(使用说明)
|
||||
|
||||
- 新决定入队:拿下一号(当前最大 D-19,下一号 D-20);就现代决可登记不开条目,但状态必须写 auto_decided 并带理由+推翻条件。
|
||||
- 用户翻案:旧行标 overturned 挂新行,受影响文档节重写(本台账只记录,不代改)。
|
||||
- 概念层变更定稿后:速览卡"决定状态与原型验证项"字段随本文件最新版同步。
|
||||
- 体力与战斗是否共享资源:涉及探索压力和恢复规则,结合用户对战斗体验的要求确认,再更新相关系统设计。
|
||||
- 背包使用格子还是重量限制:在确定存档和界面结构前确认,选择依据记录在分析文档。
|
||||
- 战斗判定窗口的手感:通过矿井原型试玩观察是否易懂、是否拖慢探索,依据见分析文档“战斗判定窗口是否需要调整”。
|
||||
- 天气权重是否合适:用前几天的游玩样本检查连续雨天或长期无雨的影响,再调整配置。
|
||||
|
||||
+24
-25
@@ -1,59 +1,58 @@
|
||||
# 速览卡:《星露谷物语》金样项目
|
||||
|
||||
> 字段来源见每节尾注(概念层/定调记录/决策台账)。概念层变更定稿后本卡必须同步更新。
|
||||
> 本卡概括当前游戏;仅在核心体验、范围、平台等概览内容变化时更新。具体规则和取舍依据见对应设计与分析文档。
|
||||
|
||||
## 1. 游戏名称
|
||||
《星露谷物语》(金样项目沿用案例名;新项目由概念层第 1 节定名)←概念层§1
|
||||
《星露谷物语》(金样项目沿用案例名)←概念设计标题
|
||||
|
||||
## 2. 一句话描述
|
||||
继承一座荒废农场的乡村生活 RPG:安排每天的时间与体力,种田、探索、交朋友,把日子过成自己想要的样子。(←概念层§1 一句话概念,45~90 字)
|
||||
继承一座荒废农场的乡村生活 RPG:安排每天的时间与体力,种田、探索、交朋友,把日子过成自己想要的样子。(←概念层「游戏概念」)
|
||||
|
||||
## 3. 游戏分类
|
||||
乡村生活模拟 RPG(经营+探索+社交;参照系牧场物语)←概念层§1
|
||||
乡村生活模拟 RPG(经营+探索+社交;参照系牧场物语)←概念层「游戏概念」
|
||||
|
||||
## 4. 美术风格(四件)←定调记录+概念层§4
|
||||
## 4. 美术风格(四件)←概念层「身份、基调与世界观」与美术圣经
|
||||
- 视觉类型:手绘感像素风、俯视 45° 视角。
|
||||
- 风格关键词:温暖、田园、四季分明、生活感。
|
||||
- 色彩与氛围:暖土绿基底+季节信号色整体切换;治愈不压抑;无锐利科技感、无阴暗元素。
|
||||
- MVP 美术边界:首期 3 区域 tileset、8 位 NPC(行走+立绘)、约 120 物品图标、玩家换装 5 层;不做 19 层全量换装与全区域。
|
||||
|
||||
## 5. 游戏支柱(3 条)←设计锚点提炼
|
||||
## 5. 游戏支柱(3 条)←概念层「游戏概念」「体验与玩法」
|
||||
| 支柱 | 玩家感受 | 实现机制 |
|
||||
|---|---|---|
|
||||
| 自己的节奏 | "今天想干嘛就干嘛,明天一切更顺手。" | 自由日程+时间体力预算;无失败结局 |
|
||||
| 今天的选择让明天更从容 | "升级工具、攒钱扩建是有意义的。" | 长期投资线:工具升级/技能/设施 |
|
||||
| 社区让独居变成归属 | "镇上的人在等我。" | NPC 关系/任务/社区修复目标 |
|
||||
|
||||
## 6. 核心循环(5 步)←锚点循环位展开
|
||||
## 6. 核心循环(5 步)←概念层「体验与玩法」
|
||||
安排一天的时间与体力 → 农/采/钓/矿/战/社交任选组合 → 获得资源·金钱·经验·关系 → 投资工具·设施·种子·物品 → 解锁更高效或更丰富的活动。
|
||||
|
||||
## 7. 目标用户 ←概念层§5
|
||||
## 7. 目标用户 ←概念层「目标玩家与情境」
|
||||
牧场物语系慢节奏成长玩家+动森式"无压力日常"需求;单人、可反复、每次一至数个游戏日;不要求预先掌握复杂数值。
|
||||
|
||||
## 8. 平台事实(禁改)
|
||||
Web 浏览器运行 · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地启动后可在浏览器中试玩。
|
||||
|
||||
## 9. MVP 系统(5 个)←概念层"最小闭环粗清单"
|
||||
## 9. MVP 系统(本例首期范围)
|
||||
| 系统 | 最小功能 | 为什么必须有 | 验证方法 |
|
||||
|---|---|---|---|
|
||||
| 时间与日程 | 时钟/日终结算/季节天气 | 全局节拍器 | 一个游戏日全流程可完成并结算 |
|
||||
| 体力与状态 | 单池体力/昏倒惩罚 | 一切取舍的成本源 | 玩家主动在体力耗尽前收手 |
|
||||
| 农场经营 | 锄种浇收+加工队列 | 核心产出与规划场 | "买种→收获→出售"闭环成立 |
|
||||
| 物品与制作 | item_id/背包/配方解锁 | 资源身份与转化 | 拾取/堆叠/制作全链无回翻 GDD |
|
||||
| 经济与商店 | 基准价+价差+出货箱 | 投资回报换算 | 第 4 日现金流回正(前五日验算) |
|
||||
| 时间与日程 | 时钟、天气、日终协调与跨日推进 | 组织日常活动 | 单日行动与多日状态持续一致 |
|
||||
| 体力与状态 | 行动成本、休息恢复 | 支持日常计划与取舍 | 结合行动调整和玩家反馈判断压力 |
|
||||
| 农场经营 | 耕种、浇水、生长与收获 | 核心产出与规划场 | 连续数日完成生长、收获与再投资 |
|
||||
| 探索与地图 | 农场、小镇与基础采集区域 | 支持外出和活动选择 | 移动、出入口与资源点状态正确 |
|
||||
| 采集与钓鱼 | 本期基础采集,钓鱼后续加入 | 提供农场外的资源来源 | 采集结果正确入账且不重复领取 |
|
||||
| 物品与制作 | 本期物品身份、背包与工具使用 | 连接活动成果与投资 | 拾取、消耗及存读档结果一致 |
|
||||
| 成长与技能 | 基础农务或采集成长 | 为后续活动提供目标 | 多日成果产生可理解的能力变化 |
|
||||
| 经济与商店 | 买种、出售与基础投资 | 连接产出与后续投入 | 收益可用于下一轮活动,具体节奏待验算与试玩 |
|
||||
|
||||
## 10. 制作边界 ←概念层"不是什么"表
|
||||
## 10. 制作边界 ←概念层「边界与约束」
|
||||
不做:硬核生存(无饥饿/债务/死亡惩罚);效率至上的工厂经营;以战斗为核心的动作游戏;剧情驱动的任务链主线;多人竞争;无边界开放世界(区域小网络全步行可达)。
|
||||
|
||||
## 11. 创作者提示(先做与验证)←概念层"先做与验证"节
|
||||
- 先做:第 1 日循环(买种→播种→浇灌→收获→出售→日终结算)+一个可进入的矿井遭遇。
|
||||
## 11. 创作者提示(本例原型验证安排)
|
||||
- 先做:单日农务与基础采集,继续数日覆盖作物生长、收获、出售、投资和基础成长,包含必要的 UI 与存读档。
|
||||
- 暂不做:装备刷取、随机构筑、复杂剧情、节日全量、联机。
|
||||
- 这样验证:测试者玩完第 1 日后是否主动说"再玩一天";能否说出"明天要先做什么"。
|
||||
- 达标再扩展:玩家能自述明日计划后,才加社交深度与矿井分层。
|
||||
- 这样验证:结合试玩观察和玩家对选择理由、后续目标的说明,判断是否形成有意义的计划;同时检查资源与跨日状态的一致性。
|
||||
- 后续验证:加入一个矿井遭遇,再逐步覆盖关系与社区目标;依据实际问题调整范围,不把能自述计划作为唯一门槛。
|
||||
|
||||
## 12. 决定状态与原型验证项(依据决策台账)
|
||||
- 已确认(confirmed):定调 D-01 / 单人 D-02 / 战斗伴生 D-03 / 日目标自设 D-07 / 活动统一接口 D-11 / 战斗节奏判定 D-13。
|
||||
- 技术代决(auto_decided,可一键翻案):D-04 时间片 / D-05 分区域切换 / D-06 28 日季 / D-09 营业条件 / D-10 出货箱日终 / D-12 工具占用 / D-16 矿井生成 P2 / D-17 换装 5 层 / D-18 品质三档(推翻 D-08 两档)。
|
||||
- 等拍板(pending_user):D-14 体力战斗是否共享单池(暂按共享实现);D-15 背包格子/重量(暂按格子制,B 级阻断存档结构)。
|
||||
- 待原型(prototype_pending):D-13b 战斗判定窗口手感(前摇帧数/450ms 无敌帧基准)。
|
||||
- 默认兜底(default_pending):D-19 天气权重数值(默认已入表,带 designer_note)。
|
||||
## 12. 待原型验证项
|
||||
- 矿井中的轻度战斗能否提供节奏变化,同时保持探索流畅;通过原型试玩观察判定是否易懂、战斗是否拖慢探索。
|
||||
|
||||
+73
-179
@@ -1,200 +1,94 @@
|
||||
# 系统架构:《星露谷物语》
|
||||
|
||||
## 架构定位与目标
|
||||
本阶段确定"哪些系统支撑一轮玩法",不展开单系统内部规则。
|
||||
划分原则:将生活模拟 RPG 拆成职责清晰、可独立讨论的规则系统,同时保留少量跨系统入口,避免"每个功能都能互相调用"造成架构失控。系统划分服务于顶层循环:安排一天、执行活动、获得进展、投入成长、解锁新选择。
|
||||
## 系统与职责
|
||||
|
||||
一句话架构:
|
||||
> 玩家在有限的时间与体力下,通过农场、探索与社交三组活动系统产出资源与关系,经物品与经济系统转化为投资,由时间系统推进日终,把一天的成果变成下一天的选择。
|
||||
架构承接顶层的农场生活体验:安排一天、执行活动、获得进展、投入成长,再形成后续计划。战斗服务于探索中的风险与节奏变化,不作为装备成长主轴。以下系统覆盖完整版本,首个原型只实现其中必要的能力。
|
||||
|
||||
变更记录:
|
||||
- 2026-09-05:战斗与敌人系统定为伴生风险定位,深度刻意受限,不进入最小闭环核心链(依据:概念分析 D-03)。
|
||||
|
||||
## 系统地图
|
||||
|
||||
| 编号 | 系统 | 一句话职责 | 优先级 |
|
||||
| 编号 | 系统 | 职责与权威维护的状态 | 首个原型范围 |
|
||||
|---|---|---|---|
|
||||
| S01 | 时间与日程 | 推进游戏时间、日期、季节、天气、营业时间、NPC 日程和日终结算 | P0 |
|
||||
| S02 | 体力与状态 | 管理体力、负面状态、恢复、昏倒和行动成本 | P0 |
|
||||
| S03 | 农场经营 | 管理土地、作物、畜牧、农场设施和生产状态 | P0 |
|
||||
| S04 | 探索与地图 | 管理区域、出入口、可交互资源点、地图解锁和移动 | P0(基础) |
|
||||
| S05 | 采集与钓鱼 | 管理野外采集、钓鱼活动、资源品质和获得物 | P1 |
|
||||
| S06 | 战斗与敌人 | 管理矿区或危险区域中的战斗、伤害、敌人行为和战利品 | P1 |
|
||||
| S07 | 物品、背包与制作 | 管理物品实例、堆叠、工具、装备、配方和制作队列 | P0 |
|
||||
| S08 | 成长与技能 | 管理技能经验、等级、工具升级、职业选择和能力解锁 | P0(基础) |
|
||||
| S09 | 经济与商店 | 管理货币、买卖、价格、商店库存、订单和资金流 | P0 |
|
||||
| S10 | NPC 与关系 | 管理 NPC 日程、对话、好感度、礼物偏好和关系事件 | P1 |
|
||||
| S11 | 任务与社区目标 | 管理任务状态、阶段目标、奖励、社区修复和区域解锁条件 | P1 |
|
||||
| S12 | 事件与节日 | 管理季节事件、节日活动、条件触发和特殊奖励 | P1 |
|
||||
| S01 | 时间与日程 | 时钟、日期、季节、天气,时间通知与日终流程协调 | 基础时间、天气与跨日推进 |
|
||||
| S02 | 体力与状态 | 体力、恢复、昏倒和状态效果,处理活动提交的成本 | 农务与采集的行动成本、休息恢复 |
|
||||
| S03 | 农场经营 | 土地、作物、畜牧、设施生产状态与生产规则 | 耕种、浇水、生长与收获 |
|
||||
| S04 | 探索与地图 | 区域、出入口、角色位置、资源点位置与可用状态,执行移动和区域开放 | 农场、小镇与基础采集区域 |
|
||||
| S05 | 采集与钓鱼 | 活动判定、获得物与品质规则 | 基础采集;钓鱼后续加入 |
|
||||
| S06 | 战斗与敌人 | 战斗过程、敌人状态、伤害和战利品请求 | 后续矿井遭遇原型 |
|
||||
| S07 | 物品、背包与制作 | 物品身份、实例、容器、配方、工具装备及通用制作队列 | 种子、工具、采集物和农产品的持有与使用 |
|
||||
| S08 | 成长与技能 | 经验、等级、能力与配方解锁条件 | 基础农务或采集成长 |
|
||||
| S09 | 经济与商店 | 货币、价格、交易、库存及营业条件 | 买种、出售与基础投资 |
|
||||
| S10 | NPC 与关系 | NPC 日程内容与执行进度、对话、好感和关系事件 | 后续关系原型 |
|
||||
| S11 | 任务与社区目标 | 任务状态、奖励、社区进度和区域解锁条件 | 后续社区目标原型 |
|
||||
| S12 | 事件与节日 | 节日内容、触发条件、活动流程与完成状态 | 后续节日内容 |
|
||||
|
||||
支撑层(不拥有核心规则):
|
||||
- 存档与进度系统:保存跨日、跨季节和跨阶段的持久状态。
|
||||
- UI 与文本呈现层:展示状态、提供操作入口、呈现反馈与文本。
|
||||
存档保存各系统的持久状态并按归属恢复;UI 与文本呈现展示结果、提供操作入口。它们需要实现规格,但不另行维护玩法规则,规格在 TDD 中展开。
|
||||
|
||||
P0 段:
|
||||
容易混淆的边界:
|
||||
|
||||
| 系统 | 目的 | 输入 | 输出 | P0 原因 |
|
||||
|---|---|---|---|---|
|
||||
| S01 时间与日程 | 全局时钟与日终 | 各系统行动完成信号、日终触发 | 日期/季节/天气变化、日终结算、跨天 tick | 没有"一天",规划与取舍失去标尺 |
|
||||
| S02 体力与状态 | 全局行动成本 | 各系统行动请求、食物与休息 | 体力变化、昏倒、状态效果 | 没有它,"想做的事多于做得到的"不成立 |
|
||||
| S03 农场经营 | 核心产出与规划场 | 时间 tick、种子与工具、体力 | 作物畜产品、设施生产状态 | 概念核心承诺的载体 |
|
||||
| S04 探索与地图 | 活动场景与空间约束 | 移动指令、区域解锁条件 | 位置、区域状态、资源点入口 | 没有空间结构,农/矿/镇一体失去意义 |
|
||||
| S07 物品与制作 | 资源身份与转化 | 各系统获得物、配方请求 | 物品实例、制作结果 | 所有系统产出的公共语言 |
|
||||
| S08 成长与技能 | 长期回报层 | 各活动经验提交 | 等级、能力与配方解锁 | 长期动机的最小载体 |
|
||||
| S09 经济与商店 | 投资与回报换算 | 物品、金钱 | 价格、交易、库存 | 没有它,"变现 vs 投资"张力无载体 |
|
||||
- S01 提供时钟与日期,S10 根据自身日程决定 NPC 的目标和行动,通过 S04 执行移动;S09 判断商店是否营业。时间系统不维护另一套居民日程或商店规则。
|
||||
- S04 维护资源点的位置和是否仍可采集,S05 判定本次采集的结果;物品入账由 S07 处理,经验由 S08 处理。
|
||||
- S03 管理农场设施的生产状态,S07 管理背包与通用制作。共用配方时引用同一配方定义,不各自复制材料与产出规则。
|
||||
- S11 判断社区目标是否满足解锁条件,S04 维护实际开放的区域;S08 管理技能解锁,S07 据此判断配方或工具能否使用。
|
||||
|
||||
## 系统职责
|
||||
## 协作与数据归属
|
||||
|
||||
| 系统 | 主要职责 | 不负责 → 移交谁 |
|
||||
|---|---|---|
|
||||
| S01 时间与日程 | 时间推进、日期、季节、天气、营业与日终 | 直接决定某项活动的奖励 → 各活动系统 |
|
||||
| S02 体力与状态 | 行动消耗、恢复、昏倒、状态效果 | 农作物或敌人的具体配置 → S03/S06 |
|
||||
| S03 农场经营 | 土地、作物、畜牧、设施生产 | 商店买卖规则和角色技能 → S09/S08 |
|
||||
| S04 探索与地图 | 区域连接、进入条件、资源点位置、移动 | 具体掉落概率和战斗公式 → S05/S06 |
|
||||
| S05 采集与钓鱼 | 采集和钓鱼行为、成功条件、获得物 | 物品价格和任务奖励 → S09/S11 |
|
||||
| S06 战斗与敌人 | 战斗流程、敌人状态、伤害与战利品请求 | 角色长期成长和商店价格 → S08/S09 |
|
||||
| S07 物品与制作 | 背包、物品、配方、制作与工具装备 | 物品最终经济价值的平衡目标 → S09 |
|
||||
| S08 成长与技能 | 经验、等级、技能分支、能力解锁 | 单次行动的基础奖励 → 各活动系统 |
|
||||
| S09 经济与商店 | 货币、交易、库存、订单、价格 | 任务剧情与 NPC 情感变化 → S10/S11 |
|
||||
| S10 NPC 与关系 | 日程、互动、好感、关系事件 | 全局季节推进和商店库存 → S01/S09 |
|
||||
| S11 任务与社区 | 目标、前置、奖励、社区进度和解锁 | NPC 的日常行为表现 → S10 |
|
||||
| S12 事件与节日 | 周期事件、特殊流程和限定内容 | 常规日常行动的基础规则 → 各活动系统 |
|
||||
以下表格描述行动处理和通知,不把所有关系混成同一种依赖箭头。
|
||||
|
||||
职责说明:
|
||||
|
||||
### S01 时间与日程系统
|
||||
负责一天制的节奏规则:什么时候推进日期、哪些系统收到跨天 tick、日终结算何时发生。它不负责奖励结算,也不负责作物成长规则——只负责"什么时候"和"谁被通知"。
|
||||
|
||||
### S06 战斗与敌人系统
|
||||
负责战斗内状态、敌人行为与战利品请求。它不直接修改商店价格或 NPC 好感,不负责角色长期成长;战利品只提交请求,由 S07 物品系统入账。定位是矿井探索的风险与节奏变化,不是成长主轴(D-03)。
|
||||
|
||||
### S07 物品、背包与制作系统
|
||||
负责物品身份、容器、堆叠与配方队列。它是全项目的公共语言层——任何系统的产出都以 `item_id` 入账;它不负责物品的经济价值平衡,价格只由 S09 维护。
|
||||
|
||||
## 依赖与数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1[S01 时间与日程] --> S2[S02 体力与状态]
|
||||
S1 --> S3[S03 农场经营]
|
||||
S1 --> S4[S04 探索与地图]
|
||||
S1 --> S10[S10 NPC 与关系]
|
||||
S1 --> S12[S12 事件与节日]
|
||||
S3 --> S7[S07 物品与制作]
|
||||
S4 --> S5[S05 采集与钓鱼]
|
||||
S4 --> S6[S06 战斗与敌人]
|
||||
S5 --> S7
|
||||
S6 --> S7
|
||||
S7 --> S9[S09 经济与商店]
|
||||
S7 --> S8[S08 成长与技能]
|
||||
S8 --> S7
|
||||
S10 --> S11[S11 任务与社区]
|
||||
S12 -.读取日期季节.-> S1
|
||||
UI[UI 与文本呈现] -.读取状态.-> S1
|
||||
UI -.读取状态.-> S3
|
||||
UI -.读取状态.-> S7
|
||||
SAVE[存档与进度] -.订阅持久状态.-> S1
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[时间/体力] --> ACT[玩家行动]
|
||||
ACT --> GAIN[物品·金钱·经验·关系·任务进度]
|
||||
GAIN --> INV[制作·交易·升级·解锁]
|
||||
INV --> NEW[新的行动选择]
|
||||
NEW --> ACT
|
||||
```
|
||||
|
||||
主要状态:
|
||||
- 全局状态:日期、季节、天气、当前时间、已解锁区域、社区进度。
|
||||
- 玩家状态:位置、体力、生命、技能等级、工具、装备、背包和金钱。
|
||||
- 场景状态:土地、作物成长、设施生产、资源点、敌人和宝箱。
|
||||
- 社会状态:NPC 位置、关系值、已触发事件、任务阶段和节日参与状态。
|
||||
|
||||
主数据归属规则:
|
||||
- 规则文档描述"如何计算"和"何时发生";数据表描述"有哪些对象"和"每个对象的配置"。
|
||||
- 系统之间通过稳定 ID 关联(物品 ID、NPC ID、区域 ID、任务 ID、配方 ID)。
|
||||
- 任何系统都不复制另一系统的主数据;任务只引用物品 ID,不重新定义物品价格。
|
||||
|
||||
## 核心循环覆盖检查
|
||||
|
||||
| 顶层循环环节 | 认领系统 |
|
||||
| 行动或时机 | 协作与结果归属 |
|
||||
|---|---|
|
||||
| 查看天气、日程与目标 | S01、S11、S12、UI |
|
||||
| 选择活动并移动 | S04、S02 |
|
||||
| 农务与生产 | S03、S07、S02 |
|
||||
| 采集、钓鱼与战斗 | S04、S05、S06、S07 |
|
||||
| 出售、购买与投资 | S09、S07、S03 |
|
||||
| 社交与委托 | S10、S11、S07 |
|
||||
| 日终结算与保存 | S01、S12、存档、UI |
|
||||
| 播种与农务 | S03 检查地块及行动条件,S07 检查种子或工具,S02 检查行动成本;确认可执行后更新各自状态。失败时不留下仅扣种子或体力的部分结果 |
|
||||
| 采集 | S04 确认资源点可用,S05 判定获得物,S07 入账,S08 接收活动经验;成功后由 S04 更新资源点状态 |
|
||||
| 出售与购买 | S09 校验营业、库存与价格,S07 校验物品和容器;交易成功时双方分别更新所拥有的状态,失败时保持原状态 |
|
||||
| 成长与解锁 | 活动系统报告成果,S08 更新经验与能力;S07 等使用方读取解锁结果,不自行维护另一套技能进度 |
|
||||
| 日终 | S01 停止当日行动并协调结算;S03 推进作物与生产、S09 结算出货、S08 结算成长,之后汇总反馈并保存。跨日通知让各系统准备次日状态 |
|
||||
| 居民行动与节日 | S10、S12 读取 S01 的日期与时间,按各自规则决定活动;需要移动时交给 S04,不反过来推进全局时钟 |
|
||||
|
||||
## 目录映射
|
||||
跨系统行动的提交方式、失败处理和精确结算顺序在系统文档与 TDD 中展开,须满足上述结果一致性。日终保存应包含已经完成的结算,不能读档后重复发放同一次收益。
|
||||
|
||||
| 目录 | 本阶段定位 |
|
||||
系统通过 `item_id`、`npc_id`、`region_id`、`recipe_id` 等稳定标识关联。物品身份与实例归 S07,价格归 S09,关系值归 S10;任务、界面和存档可以引用或展示这些结果,不独立修改对应事实。UI 从权威状态刷新,只读展示副本不承担结算;存档快照在结算完成后生成,读档时恢复到对应系统。
|
||||
|
||||
跨系统共享的约束:
|
||||
|
||||
- 时间与生产使用一致的游戏时间单位,行动成本、制作时长与跨日成长须说明对应关系。顶层暂定常规游戏日约 10~20 分钟,实际换算与暂停规则在后续规格中明确并试玩验证。
|
||||
- 金钱由 S09 统一结算,各活动提供产物或交易请求;经验是持续积累的进展,不作为货币消费。
|
||||
- 失败后果按顶层场景分别处理:矿井倒下可能损失部分钱物,换季可能使作物枯萎,同时保留大部分长期进展。涉及体力、物品、金钱或位置的变化由各自负责系统执行。
|
||||
- 存档、UI 和活动系统使用相同的状态含义,避免显示已获得但实际未入账、或已结算却未保存的结果。
|
||||
|
||||
## 系统文档映射
|
||||
|
||||
本例分别展开各系统,以下是策划工作区的文档目录;实现代码如何拆模块由 TDD 决定。
|
||||
|
||||
| 系统 | 文档目录 |
|
||||
|---|---|
|
||||
| 03_systems/S01_time_schedule/ | 时间推进、日期季节天气、营业时段、日终结算 |
|
||||
| 03_systems/S02_stamina_status/ | 体力、状态效果、昏倒与恢复 |
|
||||
| 03_systems/S03_farm_management/ | 土地、作物、畜牧、设施生产 |
|
||||
| 03_systems/S04_exploration_map/ | 区域、连接、资源点、解锁与移动 |
|
||||
| 03_systems/S05_foraging_fishing/ | 采集、钓鱼、品质与获得物 |
|
||||
| 03_systems/S06_combat_enemies/ | 战斗、敌人行为、伤害与战利品请求 |
|
||||
| 03_systems/S07_items_inventory_crafting/ | 物品、背包、配方与制作队列 |
|
||||
| 03_systems/S08_progression_skills/ | 技能经验、等级、工具升级、能力解锁 |
|
||||
| 03_systems/S09_economy_shop/ | 货币、买卖、价格、库存与订单 |
|
||||
| 03_systems/S10_npc_relationship/ | NPC 日程、对话、好感与关系事件 |
|
||||
| 03_systems/S11_quests_community/ | 任务、社区目标、奖励与解锁条件 |
|
||||
| 03_systems/S12_events_festivals/ | 季节事件、节日、条件触发 |
|
||||
| 支撑层不单开系统文档 | 存档与 UI 随实现层组织,规则不独立成文 |
|
||||
| S01 时间与日程 | `project/03_systems/S01_time_schedule/` |
|
||||
| S02 体力与状态 | `project/03_systems/S02_stamina_status/` |
|
||||
| S03 农场经营 | `project/03_systems/S03_farm_management/` |
|
||||
| S04 探索与地图 | `project/03_systems/S04_exploration_map/` |
|
||||
| S05 采集与钓鱼 | `project/03_systems/S05_foraging_fishing/` |
|
||||
| S06 战斗与敌人 | `project/03_systems/S06_combat_enemies/` |
|
||||
| S07 物品、背包与制作 | `project/03_systems/S07_items_inventory_crafting/` |
|
||||
| S08 成长与技能 | `project/03_systems/S08_progression_skills/` |
|
||||
| S09 经济与商店 | `project/03_systems/S09_economy_shop/` |
|
||||
| S10 NPC 与关系 | `project/03_systems/S10_npc_relationship/` |
|
||||
| S11 任务与社区目标 | `project/03_systems/S11_quests_community/` |
|
||||
| S12 事件与节日 | `project/03_systems/S12_events_festivals/` |
|
||||
|
||||
## MVP 最小闭环
|
||||
1. 玩家在一个游戏日内完成开垦、播种、浇灌,并看到成长状态反馈。
|
||||
2. 在时间与体力约束下选择当日主目标(农场劳动或外出)。
|
||||
3. 外出采集(或矿井轻度战斗)带回资源。
|
||||
4. 通过出售或加工获得金钱,投资种子或工具。
|
||||
5. 日终结算展示当日变化并保存。
|
||||
6. 次日作物状态变化,玩家据此形成新计划。
|
||||
7. 数个游戏日内出现第一次技能提升与配方解锁。
|
||||
## 实现范围与验证
|
||||
|
||||
如果这条闭环不成立,不应继续增加钓鱼深度、节日、社区目标或更多区域。
|
||||
首个原型包含 S01、S02、S03、S04、S05、S07、S08、S09 的上述基础能力,加上必要的 UI 与存读档。完整版本还需展开钓鱼、制作、畜牧、战斗、居民关系、社区目标与节日等能力;完整清单不等于首个原型的施工范围。
|
||||
|
||||
## 统一数值基准
|
||||
本案例采用"宽松治愈型"数值风格。全局单位:时间片、游戏日、货币、体力、经验;所有数值字段必须注明单位。
|
||||
- 时间节奏基准:单次常规行动控制在短时间片内;玩家一天应能完成农务、一个主要外出目标和少量顺路活动;早期玩家不应因一次路线失误失去整天进度。
|
||||
- 货币量级基准:主要货币只有一种;初期基础种子可用少量日常产出购买;一次普通收获不应立刻买下最高阶升级;任务奖励以补足短期资金为主,不替代生产交易。
|
||||
- 成长回报基准:前几级在正常尝试一种活动的数个游戏日内出现;升级奖励优先采用节省时间体力、扩大选择和解锁配方,而非单纯提高伤害售价;专长分支宽松可恢复。
|
||||
- 体力与风险基准:体力是规划提示不是严苛倒计时;普通农务与移动成本低,战斗、钓鱼和重型工具才产生明显取舍;失败成本采用时间、少量金钱或位置变化,不损毁进度。
|
||||
首个流程从查看天气和选择目标开始,经农务或外出采集获得进展,再通过出售、购买和日终进入下一天。单日用于观察计划与取舍;连续数日用于覆盖作物生长、收获、投资和基础成长,不要求作物一天内完成播种到收获。
|
||||
|
||||
(具体换算数值与前五日验算由技术文档层·数值策划承接。)
|
||||
|
||||
## 系统边界
|
||||
- 农场经营只管理农场内的生产状态,不负责所有资源的通用背包逻辑。
|
||||
- 探索与地图只管理"在哪里"和"能否进入",不管理每种活动的具体奖励。
|
||||
- 战斗只管理战斗内状态和战利品请求,不直接修改商店价格或 NPC 好感。
|
||||
- NPC 与关系负责互动和关系变化;任务与社区负责可验证目标,二者通过事件和条件连接。
|
||||
- UI、文本和表现不反向承载核心规则;所有关键变化必须由规则系统确认。
|
||||
- 本案例不拆出独立多人、拍卖、复杂天气模拟、动态市场或高复杂度叙事工具系统。
|
||||
|
||||
## 优先级与范围
|
||||
- P0(最小可玩闭环):时间与日程、体力、农场、物品背包、经济、基础地图、基础成长和日终结算。
|
||||
- P1(形成完整案例):采集、钓鱼、轻度战斗、NPC 关系、任务、社区目标、制作、商店、季节和节日。
|
||||
- P2(扩展内容):更多区域、敌人、作物、配方、关系事件、节日小游戏和终局后的自由活动。
|
||||
|
||||
拆分系统不等于所有系统都要在最小版本同时实现;系统独立性是为了便于协作和后续裁剪。
|
||||
|
||||
## 风险与校验
|
||||
|
||||
| 风险 | 校验方式 |
|
||||
| 验证问题 | 内容与判断依据 |
|
||||
|---|---|
|
||||
| 农场变成例行公事,失去规划感 | 玩家是否在目标选择阶段出现真实取舍与计划调整 |
|
||||
| 矿井战斗反客为主 | 战斗收益是否仍以"农场难以产出的材料"为主,而非直接金钱 |
|
||||
| 时间压力变成打卡义务 | 休闲型玩家能否自由调低日程重量而不被惩罚 |
|
||||
| 经济成长过快,后期失去决策 | 升级价格是否持续制造"效率 vs 规模"的选择 |
|
||||
| UI 泄题,探索失去意义 | 关键信息是否保留为探索发现而非全量直读 |
|
||||
| 系统间主数据重复维护 | 交叉检查:同一事实是否只有一个系统拥有写权 |
|
||||
| 基础系统是否共同支持日常计划 | 试玩农务与采集,观察时间、体力、物品和金钱变化是否一致,结合玩家说明判断选择是否有意义 |
|
||||
| 跨日成果能否支持后续计划 | 连续游玩并存读档,检查作物、交易与成长是否持续且无重复结算,结合玩家反馈判断是否形成新的目标 |
|
||||
| 战斗是否改善探索节奏 | 后续加入 S06 与矿井所需的地图、状态和物品能力,观察战斗理解、损失恢复及其对日常活动的影响 |
|
||||
| 关系与社区是否形成长期目标 | 后续加入 S10、S11 及对应内容,观察多日投入与目标选择;单日原型不据此判断长期体验 |
|
||||
|
||||
## 开放的结构问题
|
||||
- 体力与生命是否保持为两个状态,还是在轻度战斗中共享一套风险资源?
|
||||
- NPC 日程、任务条件和节日事件之间采用统一条件格式还是各自维护?
|
||||
- 农场设施生产是否由农场系统统一管理,还是交给通用制作队列?
|
||||
- 采集、钓鱼和战斗是否共享统一的"活动结果"接口?
|
||||
- 哪些系统需要独立数据表,哪些小型配置应合并为一张内容表?
|
||||
以上是验证计划,尚未形成试玩结论。出现问题时先判断是玩法目标不成立、协作职责遗漏还是实现错误,再调整相应设计与范围。
|
||||
|
||||
## 风险与未决问题
|
||||
|
||||
- 时间、体力和收益可能共同把休闲生活变成赶任务:沿用顶层验证计划,比较不同玩家的计划调整与压力反馈。
|
||||
- 农场生产、制作和交易可能重复消费或入账:在系统规格中明确提交与失败处理,在实现阶段验证中断和日终存读档后的结果。
|
||||
- 社区目标采用章节、可选收集还是组合仍需展开:在该内容进入实现范围前确定,并补齐 S11 与 S04 的解锁协作。
|
||||
- 体力与战斗生命是否共享、战斗最低深度如何确定:不阻塞基础日常原型,在战斗原型前解决,再补齐 S02、S06 及相关 TDD 规格。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user