Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
This commit is contained in:
2026-10-06 16:10:52 +08:00
314 changed files with 13094 additions and 2427 deletions
@@ -0,0 +1,182 @@
/*
* 买断制定价字段:网页发布页与 AGC 发布面板共用同一份表现与文案。
*
* 这里只补平台原语没覆盖的部分,不另造一套控件观感:
* - 外壳:`PlatformSubpanel`(`platform` 面 + `xs` 圆角 ≈ 网页 `.game-publish-fieldset` 的
* 0.7rem 描边卡片,AGC 侧同族容器是 `.game-distribution-publish-panel__publication`)。
* - 付费方式:原生 radio(屏外裁切保留语义 / 键盘 / `.checked`)+ 共享 chip 外观
* (`getPlatformCategoryChipClassName`:未选=浅底描边,选中=实心品牌填充反白)。
* - 买断价:`PlatformTextField`,本文件只补右对齐等宽数字、单位留位、错误态与层次。
*
* 选择器统一写成「根类 + 子类」而不是裸类:AGC 发布面板的
* `.game-distribution-publish-panel__fields label/input` 是后代选择器(0,1,1),会盖掉
* 原语里的 Tailwind 工具类(0,1,0)。提高一档特异性后,组件外观在两端都由这里唯一决定。
*/
.platform-game-pricing-field {
display: grid;
gap: 0.6rem;
min-width: 0;
font-size: 0.76rem;
color: var(--platform-text-strong, #3d1f10);
}
.platform-game-pricing-field .platform-game-pricing-field__modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.platform-game-pricing-field .platform-game-pricing-field__option {
position: relative;
display: inline-flex;
gap: 0;
min-width: 0;
margin: 0;
cursor: pointer;
}
/* 原生 radio 只做语义与键盘载体:`display: none` / `visibility: hidden` 会把可聚焦性和
读屏可见性一起拿掉,所以用「屏外裁切」隐藏,焦点仍落在它身上。 */
.platform-game-pricing-field .platform-game-pricing-field__option-input {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 1px;
height: 1px;
min-width: 0;
margin: 0;
border: 0;
padding: 0;
background: transparent;
overflow: hidden;
white-space: nowrap;
clip-path: inset(50%);
}
/* chip 自带高度 36px(窄屏还会降到 33px),这里统一抬到 44px 触控下限;
「免费 / 买断制」字数不同,按内容宽度会一宽一窄,给个共同下限让两枚等宽。 */
.platform-game-pricing-field .platform-game-pricing-field__option-chip {
min-width: 5.5rem;
min-height: 2.75rem;
}
.platform-game-pricing-field
.platform-game-pricing-field__option-input:focus-visible
+ .platform-game-pricing-field__option-chip {
outline: 2px solid var(--platform-input-focus-ring, #b6623f);
outline-offset: 2px;
}
.platform-game-pricing-field
.platform-game-pricing-field__option-input:disabled
+ .platform-game-pricing-field__option-chip {
cursor: not-allowed;
opacity: 0.55;
}
.platform-game-pricing-field .platform-game-pricing-field__price {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.platform-game-pricing-field .platform-game-pricing-field__price-label {
display: block;
color: var(--platform-text-strong, #3d1f10);
font-size: 0.75rem;
font-weight: 700;
line-height: 1.35;
cursor: pointer;
}
.platform-game-pricing-field .platform-game-pricing-field__price-control {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
/* 边框 / 圆角 / 底色 / 焦点环都来自 `PlatformTextField`(AGC 宿主还会用面板自己的输入框
token 覆盖,与同面板其它输入框一致),这里只补三件事:数字右对齐且等宽、给单位留位、错误态。 */
.platform-game-pricing-field .platform-game-pricing-field__price-input {
padding-right: 2.9rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
text-align: right;
}
/* 错误态。网页宿主对子面板内的输入框做了统一 remap(`src/index.css` 的
`.platform-theme :where(.platform-subpanel …) :where(input…)`,带 `!important`),
非法态在那边由宿主同族的 `[aria-invalid='true']` 分支给出;这里保留**正常权重**的同色声明,
AGC 侧没有那套 remap 时就靠这条生效。不改 `box-shadow`:那是宿主的输入高光与焦点环,
压掉会让非法字段失去键盘焦点提示。 */
.platform-game-pricing-field
.platform-game-pricing-field__price-input[aria-invalid='true'] {
border-color: var(--platform-button-danger-text, #a6402f);
background: var(--platform-button-danger-fill, rgba(255, 237, 229, 0.94));
}
/* 原生 number 的上下箭头会画在「泥点」单位左边(Chrome 一直画,Firefox hover 才画),
与单位挤在一起;这里隐藏箭头,保留 `type="number"` + `inputMode="numeric"`(移动端仍是数字键盘)。 */
.platform-game-pricing-field
.platform-game-pricing-field__price-input::-webkit-outer-spin-button,
.platform-game-pricing-field
.platform-game-pricing-field__price-input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.platform-game-pricing-field .platform-game-pricing-field__price-input {
-moz-appearance: textfield;
appearance: textfield;
}
.platform-game-pricing-field .platform-game-pricing-field__price-unit {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
color: var(--platform-text-soft, #988476);
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.platform-game-pricing-field
.platform-game-pricing-field__price-input[aria-invalid='true']
+ .platform-game-pricing-field__price-unit {
color: var(--platform-button-danger-text, #a6402f);
}
.platform-game-pricing-field .platform-game-pricing-field__hint {
color: var(--platform-text-soft, #988476);
font-size: 0.7rem;
font-weight: 500;
line-height: 1.5;
}
.platform-game-pricing-field .platform-game-pricing-field__error {
color: var(--platform-button-danger-text, #a6402f);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.45;
}
/* 窄屏(≈390px):两枚胶囊等分整行,不靠换行兜底,也不出现横向溢出。 */
@media (max-width: 480px) {
.platform-game-pricing-field .platform-game-pricing-field__modes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.platform-game-pricing-field .platform-game-pricing-field__option-chip {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,185 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
PlatformGamePricingField,
resolvePlatformGamePriceMudPoints,
} from './index';
function PricingHarness({
disabled = false,
error = '',
initialMode = 'free',
initialPriceInput = '',
}: {
disabled?: boolean;
error?: string;
initialMode?: 'free' | 'paid';
initialPriceInput?: string;
}) {
const [mode, setMode] = useState<'free' | 'paid'>(initialMode);
const [priceInput, setPriceInput] = useState(initialPriceInput);
return (
<PlatformGamePricingField
mode={mode}
priceInput={priceInput}
disabled={disabled}
error={error}
onModeChange={setMode}
onPriceInputChange={setPriceInput}
/>
);
}
test('默认免费只渲染付费方式,不渲染买断价输入', () => {
render(<PricingHarness />);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'checked',
true,
);
expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty(
'checked',
false,
);
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('切到买断制后显示价格输入、上限提示,并回传输入值', () => {
render(<PricingHarness />);
fireEvent.click(screen.getByRole('radio', { name: '买断制' }));
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
expect(priceInput.value).toBe('');
expect(screen.getByText(/最多 1000000 泥点/u)).toBeTruthy();
fireEvent.change(priceInput, { target: { value: '240' } });
expect(priceInput.value).toBe('240');
fireEvent.click(screen.getByRole('radio', { name: '免费' }));
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('禁用态锁住单选与买断价输入', () => {
// 免费模式下价格输入根本不渲染,只断言两个 radio 等于没测到输入框;
// 必须用「买断制 + 禁用」才能覆盖价格输入被锁住的路径。
// 外壳已由 fieldset 换成 div,禁用不再级联:这里逐个断言三个控件都被锁住。
render(
<PricingHarness disabled initialMode="paid" initialPriceInput="120" />,
);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'disabled',
true,
);
const paidRadio = screen.getByRole('radio', { name: '买断制' });
expect(paidRadio).toHaveProperty('disabled', true);
expect(paidRadio).toHaveProperty('checked', true);
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
expect(priceInput.disabled).toBe(true);
expect(priceInput.value).toBe('120');
});
test('使用方传入的校验错误在字段内以 alert 呈现', () => {
render(<PricingHarness error="买断价必须是整数泥点" />);
expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点');
});
test('买断价校验错误与上限提示一起通过 aria-describedby 关联到价格输入', () => {
render(
<PricingHarness
initialMode="paid"
initialPriceInput="0"
error="买断价必须是整数泥点"
/>,
);
const priceInput = screen.getByLabelText(/买断价/u);
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('买断价必须是整数泥点');
expect(alert.id).toBeTruthy();
// 描述里必须同时有错误行与上限提示:只挂错误行会让「最多 1000000 泥点」读不到。
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
.split(/\s+/u)
.filter(Boolean);
expect(describedByIds).toContain(alert.id);
const describedText = describedByIds
.map((id) => document.getElementById(id)?.textContent ?? '')
.join(' ');
expect(describedText).toContain('买断价必须是整数泥点');
expect(describedText).toContain('1000000');
expect(priceInput.getAttribute('aria-invalid')).toBe('true');
});
test('没有校验错误时价格输入仍通过 aria-describedby 播报上限提示', () => {
render(<PricingHarness initialMode="paid" initialPriceInput="240" />);
const priceInput = screen.getByLabelText(/买断价/u);
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
.split(/\s+/u)
.filter(Boolean);
expect(describedByIds).toHaveLength(1);
expect(
document.getElementById(describedByIds[0] ?? '')?.textContent,
).toContain('1000000');
expect(priceInput.getAttribute('aria-invalid')).toBeNull();
});
test('价格解析与提交口径一致:免费为 0,买断制要求 1..=1000000 整数', () => {
expect(resolvePlatformGamePriceMudPoints('free', 'abc')).toEqual({
priceMudPoints: 0,
error: '',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1000000')).toEqual({
priceMudPoints: PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
error: '',
});
expect(resolvePlatformGamePriceMudPoints('paid', '')).toEqual({
priceMudPoints: 0,
error: '买断价必须是整数泥点',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1.5')).toEqual({
priceMudPoints: 0,
error: '买断价必须是整数泥点',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1000001')).toEqual({
priceMudPoints: 0,
error: '买断价必须是 1 到 1000000 之间的整数泥点',
});
});
test('onModeChange 与 onPriceInputChange 只回传使用方状态', () => {
const onModeChange = vi.fn();
const onPriceInputChange = vi.fn();
const { rerender } = render(
<PlatformGamePricingField
mode="free"
priceInput=""
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: '买断制' }));
expect(onModeChange).toHaveBeenCalledWith('paid');
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
rerender(
<PlatformGamePricingField
mode="paid"
priceInput="0"
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '12' },
});
expect(onPriceInputChange).toHaveBeenCalledWith('12');
});
@@ -0,0 +1,155 @@
import './PlatformGamePricingField.css';
import { useId } from 'react';
import { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
import { PlatformFieldLabel } from './PlatformFieldLabel';
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
type PlatformGamePricingMode,
} from './platformGamePricingModel';
import { PlatformSubpanel } from './PlatformSubpanel';
import { PlatformTextField } from './PlatformTextField';
export type PlatformGamePricingFieldProps = {
/** 付费方式;`free` 等价于 0 泥点。 */
mode: PlatformGamePricingMode;
/** 买断价原始输入(受控字符串),校验由使用方持有。 */
priceInput: string;
disabled?: boolean;
/** 使用方算出的校验结果;为空串或省略时不渲染错误行。 */
error?: string;
onModeChange: (mode: PlatformGamePricingMode) => void;
onPriceInputChange: (value: string) => void;
/** 同页存在多组定价字段时的单选组名;省略时用默认值。 */
radioName?: string;
};
/**
* 平台游戏买断制定价字段。
*
* 网页发布页与 AGC 发布面板共用同一份表现和文案;组件只表达输入 UI,价格校验、提交
* 与业务状态由使用方持有。上限取自共享常量,两端不各写一份。
*
* 组成全部复用平台原语:外壳是 `PlatformSubpanel`(与网页发布页 `game-publish-fieldset`
* 同族的描边卡片),付费方式是「原生 radio + 标签 chip」,价格是 `PlatformTextField`。
* 两个选项是互斥单选,所以语义仍由原生 radio 承载(读屏的单选组、方向键切换、
* `.checked` / `.disabled`、label 关联全都照旧),chip 只负责外观。
*/
export function PlatformGamePricingField({
mode,
priceInput,
disabled = false,
error = '',
onModeChange,
onPriceInputChange,
radioName = 'platform-game-pricing-mode',
}: PlatformGamePricingFieldProps) {
// 错误行与价格输入必须显式关联:读屏用户只听到「买断价(泥点)」时不知道哪里非法。
const errorId = `${useId()}-price-error`;
const priceInputId = `${useId()}-price-input`;
// 提示句同样要挂在输入上:它是「最多 1000000 泥点」这类上限信息,光靠 label 念不到。
const hintId = `${useId()}-price-hint`;
const describedBy = [hintId, error ? errorId : null]
.filter(Boolean)
.join(' ');
return (
<PlatformSubpanel
as="div"
className="platform-game-pricing-field"
radius="xs"
padding="xs"
surface="platform"
title={<PlatformFieldLabel variant="field">付费方式</PlatformFieldLabel>}
titleVariant="strong"
>
<div
className="platform-game-pricing-field__modes"
role="radiogroup"
aria-label="付费方式"
>
<label className="platform-game-pricing-field__option">
<input
className="platform-game-pricing-field__option-input"
type="radio"
name={radioName}
checked={mode === 'free'}
disabled={disabled}
onChange={() => onModeChange('free')}
/>
<span
className={[
'platform-game-pricing-field__option-chip',
getPlatformCategoryChipClassName(mode === 'free'),
].join(' ')}
>
免费
</span>
</label>
<label className="platform-game-pricing-field__option">
<input
className="platform-game-pricing-field__option-input"
type="radio"
name={radioName}
checked={mode === 'paid'}
disabled={disabled}
onChange={() => onModeChange('paid')}
/>
<span
className={[
'platform-game-pricing-field__option-chip',
getPlatformCategoryChipClassName(mode === 'paid'),
].join(' ')}
>
买断制
</span>
</label>
</div>
{mode === 'paid' ? (
<div className="platform-game-pricing-field__price">
<label
className="platform-game-pricing-field__price-label"
htmlFor={priceInputId}
>
买断价(泥点)
</label>
<div className="platform-game-pricing-field__price-control">
<PlatformTextField
id={priceInputId}
className="platform-game-pricing-field__price-input"
type="number"
inputMode="numeric"
size="md"
density="compact"
value={priceInput}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
onChange={(event) => onPriceInputChange(event.target.value)}
/>
{/* 单位是价格的视觉附属:label 里已经写了「(泥点)」,这里对读屏重复一次没有意义。 */}
<span
className="platform-game-pricing-field__price-unit"
aria-hidden="true"
>
泥点
</span>
</div>
<span id={hintId} className="platform-game-pricing-field__hint">
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
{PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 泥点
</span>
</div>
) : null}
{error ? (
<span
id={errorId}
className="platform-game-pricing-field__error"
role="alert"
>
{error}
</span>
) : null}
</PlatformSubpanel>
);
}
@@ -21,6 +21,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
game_purchase: '购买游戏',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type ProfileWalletLedgerEntryPresentation = {
+7
View File
@@ -56,6 +56,13 @@ export {
PLATFORM_GAME_LOADING_TIMEOUT_MS,
resolvePlatformGameLoadingMessage,
} from './platformGameLoadingSurfaceModel';
export type { PlatformGamePricingFieldProps } from './PlatformGamePricingField';
export { PlatformGamePricingField } from './PlatformGamePricingField';
export type { PlatformGamePricingMode } from './platformGamePricingModel';
export {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
resolvePlatformGamePriceMudPoints,
} from './platformGamePricingModel';
export type {
PlatformIconBadgeProps,
PlatformIconBadgeShape,
@@ -0,0 +1,33 @@
/** 买断价上限,与服务端 `MAX_GAME_PRICE_MUD_POINTS` 保持一致。 */
export const PLATFORM_GAME_MAX_PRICE_MUD_POINTS = 1_000_000;
export type PlatformGamePricingMode = 'free' | 'paid';
/**
* 解析付费方式与买断价输入。
*
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
* 返回的 `error` 为空字符串表示可用,否则 `priceMudPoints` 无意义。
*/
export function resolvePlatformGamePriceMudPoints(
mode: PlatformGamePricingMode,
rawValue: string,
): { priceMudPoints: number; error: string } {
if (mode === 'free') return { priceMudPoints: 0, error: '' };
const value = rawValue.trim();
if (!/^\d+$/u.test(value)) {
return { priceMudPoints: 0, error: '买断价必须是整数泥点' };
}
const priceMudPoints = Number(value);
if (
!Number.isSafeInteger(priceMudPoints) ||
priceMudPoints < 1 ||
priceMudPoints > PLATFORM_GAME_MAX_PRICE_MUD_POINTS
) {
return {
priceMudPoints: 0,
error: `买断价必须是 1 到 ${PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 之间的整数泥点`,
};
}
return { priceMudPoints, error: '' };
}
@@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = {
/**
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
*
* 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。
*/
entryUrl: string;
entryUrl: string | null;
sha256: string;
publishedAt: string;
controls: string[];
@@ -310,6 +312,15 @@ export type GameDistributionGame = {
* 排序与公开主题列表共用同一份比较器(创建时间倒序 + themeId 升序兜底)。
*/
themes?: GameDistributionThemeReference[];
/**
* 买断价(整数泥点,`0` 表示免费)。后端公开投影始终下发;作者侧与旧响应可省略
* (Rust 侧 `#[serde(default)]` 同样允许缺字段),读取方必须按 `0`(免费)兜底。
*/
priceMudPoints?: number;
/**
* 当前查看者是否已拥有;匿名与未登录恒为 `false`。旧响应可省略,读取方按未购买兜底。
*/
purchased?: boolean;
playCount: number;
createdAt: string;
};
@@ -591,6 +602,13 @@ export type GameDistributionCreateVersionRequest = {
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
*/
versionNumber?: number | null;
/**
* 作者提交的买断价(整数泥点,`0` 表示免费)。
*
* 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。
* 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。
*/
priceMudPoints?: number;
packageSha256: string;
packageBytes: number;
packageFileCount: number;
@@ -629,6 +647,8 @@ export type GameDistributionPrivateVersion = {
status: GameDistributionVersionStatus;
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
publicationRevision: number;
/** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */
priceMudPoints?: number;
reviewReason?: string | null;
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
entryUrl: string | null;
@@ -720,3 +740,36 @@ export type GameDistributionCollectionState = {
collected: boolean;
replayed?: boolean;
};
/**
* 买断制购买请求体。
*
* 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。
*/
export type GameDistributionPurchaseRequest = {
expectedPriceMudPoints: number;
};
/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */
export type GameDistributionPurchase = {
purchaseId: string;
gameId: string;
priceMudPoints: number;
createdAt: string;
};
/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */
export type GameDistributionPurchaseResponse = {
purchase: GameDistributionPurchase;
walletBalance: number;
replayed: boolean;
};
/**
* 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。
* 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。
*/
export type GameDistributionPlaySessionResponse = {
playUrl: string;
expiresAt: string;
};
+2 -1
View File
@@ -111,7 +111,8 @@ export type ProfileWalletLedgerEntry = {
| 'recharge_refund_recovery'
| 'redeem_code_reward'
| 'puzzle_author_incentive_claim'
| 'daily_task_reward';
| 'daily_task_reward'
| 'game_purchase';
createdAt: string;
reason?: string;
};