功能:战绩分享

This commit is contained in:
桃树夭 committed 2026-08-03 12:00:46 +08:00
1 parent b686347556
commit 109ee817ec
28 files changed
+7994 -101

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "wanjiedaoyou_battle_records_v3" ADD COLUMN "share_code" uuid;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_battle_records_v3" ADD COLUMN "shared_at" timestamp;--> statement-breakpoint
CREATE UNIQUE INDEX "battle_records_v3_share_code_uidx" ON "wanjiedaoyou_battle_records_v3" USING btree ("share_code");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -148,6 +148,13 @@
"when": 1785681588311,
"tag": "0020_lowly_stryfe",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1785728967815,
"tag": "0021_battle_record_sharing",
"breakpoints": true
}
]
}
@@ -1,6 +1,6 @@
import { GameLoadingState } from '@app/components/game-shell/GameLoadingState';
import { InkButton } from '@app/components/ui/InkButton';
import type { BattleRecordV3 } from '@shared/types/battle';
import type { BattlePlaybackRecordV3 } from '@shared/types/battle';
import type { CSSProperties, ReactNode } from 'react';
interface BattlePageLayoutProps {
@@ -8,7 +8,7 @@ interface BattlePageLayoutProps {
subtitle?: string;
error?: string;
loading?: boolean;
battleResult?: BattleRecordV3;
battleResult?: BattlePlaybackRecordV3;
children: ReactNode;
variant?: 'page' | 'immersive-battle';
actions?: {
@@ -0,0 +1,179 @@
import { InkModal } from '@app/components/layout/InkModal';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import type { BattleRecordUnitSummary } from '@shared/types/battle';
import { useState } from 'react';
export interface BattleShareSummary {
winner: BattleRecordUnitSummary;
loser: BattleRecordUnitSummary;
turns: number;
}
interface BattleShareDialogProps {
isOpen: boolean;
battleRecordId: string;
summary: BattleShareSummary;
onClose: () => void;
}
type ShareAction = 'copy' | 'world' | 'sect';
type ShareResponse = {
success: boolean;
data?: {
shareCode: string;
sharePath: string;
created: boolean;
};
error?: string;
};
export function BattleShareDialog({
isOpen,
battleRecordId,
summary,
onClose,
}: BattleShareDialogProps) {
const { pushToast } = useInkUI();
const [caption, setCaption] = useState('');
const [pendingAction, setPendingAction] = useState<ShareAction | null>(null);
const captionLength = Array.from(caption).length;
const captionTooLong = captionLength > 100;
const close = () => {
if (pendingAction) return;
setCaption('');
onClose();
};
const copyShareLink = async () => {
setPendingAction('copy');
try {
const response = await fetch(
`/api/battle-records/v3/${battleRecordId}/share`,
{ method: 'POST' },
);
const payload = (await response.json()) as ShareResponse;
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error || '生成分享链接失败');
}
const shareUrl = new URL(
payload.data.sharePath,
window.location.origin,
).toString();
await navigator.clipboard.writeText(shareUrl);
pushToast({ message: '战谱链接已复制', tone: 'success' });
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '复制链接失败',
tone: 'danger',
});
} finally {
setPendingAction(null);
}
};
const sendToChannel = async (channel: 'world' | 'sect') => {
if (captionTooLong) return;
setPendingAction(channel);
try {
const endpoint =
channel === 'sect'
? '/api/sects/current/chat/messages'
: '/api/world-chat/messages';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageType: 'battle_showcase',
battleRecordId,
...(caption.trim() ? { textContent: caption.trim() } : {}),
}),
});
const payload = (await response.json()) as {
success?: boolean;
error?: string;
};
if (!response.ok || !payload.success) {
throw new Error(payload.error || '展示战谱失败');
}
pushToast({
message: channel === 'sect' ? '已展示到宗门传音' : '已展示到世界传音',
tone: 'success',
});
setCaption('');
onClose();
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '展示战谱失败',
tone: 'danger',
});
} finally {
setPendingAction(null);
}
};
return (
<InkModal
isOpen={isOpen}
onClose={close}
title="展示战谱"
footer={
<div className="flex flex-wrap items-center justify-end gap-x-2 gap-y-1">
<InkButton
onClick={() => void copyShareLink()}
pending={pendingAction === 'copy'}
disabled={pendingAction !== null && pendingAction !== 'copy'}
>
复制链接
</InkButton>
<InkButton
onClick={() => void sendToChannel('world')}
pending={pendingAction === 'world'}
disabled={captionTooLong || pendingAction !== null}
>
世界传音
</InkButton>
<InkButton
variant="primary"
onClick={() => void sendToChannel('sect')}
pending={pendingAction === 'sect'}
disabled={captionTooLong || pendingAction !== null}
>
宗门传音
</InkButton>
</div>
}
>
<div className="space-y-4 text-sm leading-7">
<div className="border-ink/10 flex items-center gap-2 border-y border-dashed py-2">
<span className="text-teal min-w-0 flex-1 truncate font-semibold">
{summary.winner.name}
</span>
<span className="text-ink-secondary shrink-0">
胜 · {summary.turns} 回
</span>
<span className="text-crimson min-w-0 flex-1 truncate text-right">
{summary.loser.name}
</span>
</div>
<p className="text-crimson">
战谱一经公开将永久有效,且无法撤销;精确属性不会公开。
</p>
<InkInput
multiline
rows={3}
label="附言(可选)"
placeholder="说说这场斗法的精彩之处……"
value={caption}
onChange={setCaption}
hint={`${captionLength}/100`}
error={captionTooLong ? '附言长度需在 100 字以内' : undefined}
disabled={pendingAction !== null}
/>
</div>
</InkModal>
);
}
@@ -1,4 +1,8 @@
import type { BattleRecordV3 } from '@shared/types/battle';
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import type {
BattlePlaybackRecordV3,
PublicBattleUnitSnapshotV1,
} from '@shared/types/battle';
import { CombatAttributeModal } from '../v5/CombatAttributeModal';
import { CombatControlBar } from '../v5/CombatControlBar';
import { CombatStatusHeader } from '../v5/CombatStatusHeader';
@@ -12,15 +16,21 @@ export interface BattleStatusAction {
}
interface BattlePlaybackPanelProps {
battleResult: BattleRecordV3 | undefined;
battleResult: BattlePlaybackRecordV3 | undefined;
playback: BattlePlaybackStateV3;
statusAction?: BattleStatusAction;
statusActions?: BattleStatusAction[];
}
function hasExactAttributes(
unit: PublicBattleUnitSnapshotV1 | null | undefined,
): unit is UnitStateSnapshot {
return Boolean(unit && 'attrs' in unit && 'baseAttrs' in unit);
}
export function BattlePlaybackPanel({
battleResult,
playback,
statusAction,
statusActions,
}: BattlePlaybackPanelProps) {
if (!battleResult) {
return null;
@@ -33,11 +43,21 @@ export function BattlePlaybackPanel({
<CombatStatusHeader
player={playback.currentPlayerFrame}
opponent={playback.currentOpponentFrame}
onShowPlayerDetails={() =>
playback.openUnitDetails(playback.currentPlayerFrame ?? null)
onShowPlayerDetails={
hasExactAttributes(playback.currentPlayerFrame)
? () =>
playback.openUnitDetails(
playback.currentPlayerFrame ?? null,
)
: undefined
}
onShowOpponentDetails={() =>
playback.openUnitDetails(playback.currentOpponentFrame ?? null)
onShowOpponentDetails={
hasExactAttributes(playback.currentOpponentFrame)
? () =>
playback.openUnitDetails(
playback.currentOpponentFrame ?? null,
)
: undefined
}
controls={
<CombatControlBar
@@ -51,7 +71,7 @@ export function BattlePlaybackPanel({
onReset={playback.reset}
/>
}
statusAction={statusAction}
statusActions={statusActions}
/>
)}
@@ -62,8 +82,12 @@ export function BattlePlaybackPanel({
</div>
<CombatAttributeModal
unit={playback.selectedUnit}
isOpen={!!playback.selectedUnit}
unit={
hasExactAttributes(playback.selectedUnit)
? playback.selectedUnit
: null
}
isOpen={hasExactAttributes(playback.selectedUnit)}
onClose={playback.closeUnitDetails}
/>
</>
@@ -1,5 +1,7 @@
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecordV3 } from '@shared/types/battle';
import type {
BattlePlaybackRecordV3,
PublicBattleUnitSnapshotV1,
} from '@shared/types/battle';
import { useEffect, useMemo, useState } from 'react';
import { useCombatPlayer } from './useCombatPlayer';
@@ -15,25 +17,25 @@ export interface BattlePlaybackStateV3 {
play: () => void;
pause: () => void;
reset: () => void;
currentPlayerFrame: UnitStateSnapshot | undefined;
currentOpponentFrame: UnitStateSnapshot | undefined;
currentPlayerFrame: PublicBattleUnitSnapshotV1 | undefined;
currentOpponentFrame: PublicBattleUnitSnapshotV1 | undefined;
playerName: string;
opponentName: string;
isPlaybackFinished: boolean;
selectedUnit: UnitStateSnapshot | null;
openUnitDetails: (unit: UnitStateSnapshot | null) => void;
selectedUnit: PublicBattleUnitSnapshotV1 | null;
openUnitDetails: (unit: PublicBattleUnitSnapshotV1 | null) => void;
closeUnitDetails: () => void;
}
export function resolveSelectedBattleUnit(
selectedUnitId: string | null,
unitSnapshots: Record<string, UnitStateSnapshot>,
unitSnapshots: Record<string, PublicBattleUnitSnapshotV1>,
) {
return selectedUnitId ? (unitSnapshots[selectedUnitId] ?? null) : null;
}
export function resolveBattleUnitName(
record: BattleRecordV3 | undefined,
record: BattlePlaybackRecordV3 | undefined,
unitId: string | undefined,
fallbackName: string,
) {
@@ -52,7 +54,9 @@ export function resolveBattleUnitName(
return fallbackName;
}
export function resolveBattlePlaybackNames(record: BattleRecordV3 | undefined) {
export function resolveBattlePlaybackNames(
record: BattlePlaybackRecordV3 | undefined,
) {
return {
playerName: resolveBattleUnitName(
record,
@@ -68,7 +72,7 @@ export function resolveBattlePlaybackNames(record: BattleRecordV3 | undefined) {
}
export function useBattlePlaybackState(
record: BattleRecordV3 | undefined,
record: BattlePlaybackRecordV3 | undefined,
): BattlePlaybackStateV3 {
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null);
const {
@@ -1,16 +1,18 @@
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecordV3 } from '@shared/types/battle';
import type {
BattlePlaybackRecordV3,
PublicBattleUnitSnapshotV1,
} from '@shared/types/battle';
import { useCallback, useEffect, useMemo, useState } from 'react';
export type PlaybackState = {
record: BattleRecordV3 | undefined;
record: BattlePlaybackRecordV3 | undefined;
currentIndex: number;
isPlaying: boolean;
};
export function resolvePlaybackStateForRecord(
playbackState: PlaybackState,
record: BattleRecordV3 | undefined,
record: BattlePlaybackRecordV3 | undefined,
): PlaybackState {
return playbackState.record === record
? playbackState
@@ -22,7 +24,7 @@ export function resolvePlaybackStateForRecord(
*
* 职责:管理战斗播放状态,并提供平滑的状态快照映射。
*/
export function useCombatPlayer(record: BattleRecordV3 | undefined) {
export function useCombatPlayer(record: BattlePlaybackRecordV3 | undefined) {
const [playbackState, setPlaybackState] = useState<PlaybackState>({
record,
currentIndex: -1,
@@ -42,7 +44,7 @@ export function useCombatPlayer(record: BattleRecordV3 | undefined) {
currentRecordState.isPlaying && totalActions > 0 && !isEnded;
const latestUnitsBySequenceId = useMemo(() => {
const map = new Map<string, Record<string, UnitStateSnapshot>>();
const map = new Map<string, Record<string, PublicBattleUnitSnapshotV1>>();
for (const frame of record?.stateTimeline.frames ?? []) {
if (frame.sourceSequenceId) {
map.set(frame.sourceSequenceId, frame.units);
@@ -51,7 +53,9 @@ export function useCombatPlayer(record: BattleRecordV3 | undefined) {
return map;
}, [record]);
const unitSnapshots = useMemo<Record<string, UnitStateSnapshot>>(() => {
const unitSnapshots = useMemo<
Record<string, PublicBattleUnitSnapshotV1>
>(() => {
const initialUnits = record?.stateTimeline.frames[0]?.units;
if (!record || !initialUnits) {
return {};
@@ -1,12 +1,12 @@
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import { cn } from '@shared/lib/cn';
import type { PublicBattleUnitSnapshotV1 } from '@shared/types/battle';
import { format } from 'd3-format';
interface Props {
unit: UnitStateSnapshot | null;
unit: PublicBattleUnitSnapshotV1 | null;
}
type SkillState = UnitStateSnapshot['cooldowns'][number];
type SkillState = PublicBattleUnitSnapshotV1['cooldowns'][number];
const fmtInt = format(',d');
@@ -15,7 +15,7 @@ function SkillStateItem({
unit,
}: {
skill: SkillState;
unit: UnitStateSnapshot;
unit: PublicBattleUnitSnapshotV1;
}) {
const isOnCooldown = skill.current > 0;
const hpCost = skill.costs?.find((cost) => cost.resource === 'hp');
@@ -1,7 +1,7 @@
import { InkButton } from '@app/components/ui/InkButton';
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import { cn } from '@shared/lib/cn';
import { getResourceLabel } from '@shared/lib/gameConceptDisplay';
import type { PublicBattleUnitSnapshotV1 } from '@shared/types/battle';
import { format } from 'd3-format';
import {
useEffect,
@@ -122,7 +122,7 @@ function SummaryInfoRow({
);
}
function UnitSummary({ unit }: { unit: UnitStateSnapshot }) {
function UnitSummary({ unit }: { unit: PublicBattleUnitSnapshotV1 }) {
const statusTags = getCompactStatusTags(unit);
return (
@@ -243,18 +243,18 @@ export function CombatStatusHeader({
onShowPlayerDetails,
onShowOpponentDetails,
controls,
statusAction,
statusActions = [],
}: {
player: UnitStateSnapshot;
opponent: UnitStateSnapshot;
player: PublicBattleUnitSnapshotV1;
opponent: PublicBattleUnitSnapshotV1;
onShowPlayerDetails?: () => void;
onShowOpponentDetails?: () => void;
controls?: ReactNode;
statusAction?: {
statusActions?: Array<{
label: string;
onClick?: () => void;
href?: string;
};
}>;
}) {
const dockRef = useRef<HTMLDivElement | null>(null);
const [isCompact, setIsCompact] = useState(() => isCompactViewport());
@@ -370,7 +370,9 @@ export function CombatStatusHeader({
</p>
</div>
<div className="flex shrink-0 items-center gap-2.5">
{statusAction && <DockAction {...statusAction} />}
{statusActions.map((action) => (
<DockAction key={action.label} {...action} />
))}
<button
type="button"
onClick={toggleCollapsed}
@@ -389,7 +391,9 @@ export function CombatStatusHeader({
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-2.5 gap-y-0.5">
{statusAction && <DockAction {...statusAction} />}
{statusActions.map((action) => (
<DockAction key={action.label} {...action} />
))}
{onShowPlayerDetails && (
<button
type="button"
@@ -427,11 +431,14 @@ export function CombatStatusHeader({
)}
{controls && <div className="battle-module">{controls}</div>}
{!controls && !hasSkills && !hasActions && !statusAction && (
<div className="battle-module text-battle-muted text-[13px] leading-5">
当前暂无技能和操作项
</div>
)}
{!controls &&
!hasSkills &&
!hasActions &&
statusActions.length === 0 && (
<div className="battle-module text-battle-muted text-[13px] leading-5">
当前暂无技能和操作项
</div>
)}
</div>
)}
</div>
@@ -163,10 +163,12 @@ export function BattleOutcome({
<BattlePlaybackPanel
battleResult={battle.battle}
playback={playback}
statusAction={{
label: returnTarget.returnLabel,
onClick: () => navigate(returnTarget.route),
}}
statusActions={[
{
label: returnTarget.returnLabel,
onClick: () => navigate(returnTarget.route),
},
]}
/>
<CombatResultDialog
key={`${attemptId}-${battle.battle.outcome.turns}`}
@@ -2,12 +2,14 @@ import {
ItemDetailModal,
type ItemDetailPayload,
} from '@app/components/feature/items';
import Link from '@app/components/router/AppLink';
import type { Tier } from '@app/components/ui/InkBadge';
import { InkBadge, tierColorMap } from '@app/components/ui/InkBadge';
import { useCultivatorIdentity } from '@app/lib/resources/player';
import { cn } from '@shared/lib/cn';
import type {
ItemShowcaseSnapshotMap,
WorldChatBattleShowcasePayload,
WorldChatItemShowcasePayload,
WorldChatMessageDTO,
} from '@shared/types/world-chat';
@@ -63,6 +65,59 @@ function isItemShowcasePayload(
);
}
function isBattleShowcasePayload(
payload: WorldChatMessageDTO['payload'],
): payload is WorldChatBattleShowcasePayload {
return (
typeof payload === 'object' &&
payload !== null &&
'shareCode' in payload &&
'winner' in payload &&
'loser' in payload &&
'turns' in payload &&
typeof payload.shareCode === 'string' &&
typeof payload.winner === 'object' &&
payload.winner !== null &&
typeof payload.winner.name === 'string' &&
typeof payload.loser === 'object' &&
payload.loser !== null &&
typeof payload.loser.name === 'string' &&
typeof payload.turns === 'number'
);
}
function BattleShowcaseCard({
payload,
}: {
payload: WorldChatBattleShowcasePayload;
}) {
return (
<Link
href={`/battle-replay/${payload.shareCode}`}
className="border-ink/15 hover:border-crimson/35 mt-1 block border border-dashed bg-white/55 px-3 py-2 no-underline transition hover:bg-white/80"
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-teal min-w-0 flex-1 truncate font-semibold">
{payload.winner.name}
</span>
<span className="text-ink-secondary shrink-0 text-xs">胜</span>
<span className="text-crimson min-w-0 flex-1 truncate text-right">
{payload.loser.name}
</span>
</div>
<div className="text-ink-secondary mt-1 flex items-center justify-between gap-3 text-xs">
<span>鏖战 {payload.turns} 回</span>
<span className="text-ink">观看战谱 →</span>
</div>
{payload.text ? (
<p className="text-ink border-ink/10 mt-1.5 border-t border-dashed pt-1.5 text-sm leading-6 break-all">
{payload.text}
</p>
) : null}
</Link>
);
}
function parseShowcaseItem(payload: WorldChatItemShowcasePayload): {
name: string;
tier?: Tier;
@@ -224,6 +279,11 @@ export function WorldChatMessageItem({ message }: WorldChatMessageItemProps) {
if (!isItemShowcasePayload(message.payload)) return null;
return parseShowcaseItem(message.payload);
}, [message]);
const battleShowcase =
message.messageType === 'battle_showcase' &&
isBattleShowcasePayload(message.payload)
? message.payload
: null;
return (
<>
@@ -249,7 +309,11 @@ export function WorldChatMessageItem({ message }: WorldChatMessageItemProps) {
</span>
</div>
<div className="text-sm leading-6 break-all">
{message.messageType === 'duel_invite' ? (
{message.messageType === 'battle_showcase' && battleShowcase ? (
<BattleShowcaseCard payload={battleShowcase} />
) : message.messageType === 'battle_showcase' ? (
'【战谱展示】'
) : message.messageType === 'duel_invite' ? (
message.textContent || '赌战台有新战帖'
) : message.messageType === 'item_showcase' && showcaseData ? (
<span>
@@ -1,4 +1,5 @@
import type {
WorldChatBattleShowcasePayload,
WorldChatItemShowcasePayload,
WorldChatMessageDTO,
} from '@shared/types/world-chat';
@@ -14,6 +15,26 @@ function isTextPayload(
);
}
function isBattleShowcasePayload(
payload: WorldChatMessageDTO['payload'],
): payload is WorldChatBattleShowcasePayload {
return (
typeof payload === 'object' &&
payload !== null &&
'shareCode' in payload &&
'winner' in payload &&
'loser' in payload &&
'turns' in payload &&
typeof payload.winner === 'object' &&
payload.winner !== null &&
typeof payload.winner.name === 'string' &&
typeof payload.loser === 'object' &&
payload.loser !== null &&
typeof payload.loser.name === 'string' &&
typeof payload.turns === 'number'
);
}
function isItemShowcasePayload(
payload: WorldChatMessageDTO['payload'],
): payload is WorldChatItemShowcasePayload {
@@ -26,11 +47,24 @@ function isItemShowcasePayload(
}
export function getWorldChatMessageBody(message: WorldChatMessageDTO) {
if (
message.messageType === 'battle_showcase' &&
isBattleShowcasePayload(message.payload)
) {
const summary = `展示战谱:${message.payload.winner.name}胜${message.payload.loser.name}(${message.payload.turns}回)`;
return message.payload.text
? `${summary} ${message.payload.text}`
: summary;
}
if (message.messageType === 'duel_invite') {
return message.textContent || '赌战台有新战帖';
}
if (message.messageType === 'item_showcase' && isItemShowcasePayload(message.payload)) {
if (
message.messageType === 'item_showcase' &&
isItemShowcasePayload(message.payload)
) {
const name =
typeof message.payload.snapshot?.name === 'string'
? message.payload.snapshot.name
+33 -16
View File
@@ -15,6 +15,7 @@ export type ZhanjiRecord = {
interface ZhanjiProps {
record: ZhanjiRecord;
currentCultivatorId?: string;
onShare?: (record: ZhanjiRecord) => void;
}
function formatBattleTime(createdAt: string | null) {
@@ -36,7 +37,11 @@ function formatBattleTime(createdAt: string | null) {
return `${diffDays}天前`;
}
export default function Zhanji({ record, currentCultivatorId }: ZhanjiProps) {
export default function Zhanji({
record,
currentCultivatorId,
onShare,
}: ZhanjiProps) {
const winnerName = record.winner?.name ?? '未知';
const loserName = record.loser?.name ?? '未知';
const isWin = currentCultivatorId === record.winner?.id;
@@ -57,20 +62,32 @@ export default function Zhanji({ record, currentCultivatorId }: ZhanjiProps) {
const battleTime = formatBattleTime(record.createdAt);
return (
<Link
href={`/game/battle/${record.id}`}
className="border-ink/10 text-ink hover:border-crimson/40 block border bg-white/70 px-3 py-2 transition hover:bg-white/85"
>
<div className="flex items-center gap-2 text-sm leading-6 whitespace-nowrap">
<span className={`${outcomeColor} shrink-0 font-semibold`}>
{outcomeLabel}
</span>
<span className="min-w-0 flex-1 truncate">{opponentName}</span>
<span className="text-ink-secondary shrink-0">{turns}回</span>
<span className="text-ink-secondary shrink-0 text-xs">
{battleTime}
</span>
</div>
</Link>
<div className="border-ink/10 text-ink hover:border-crimson/40 flex border bg-white/70 transition hover:bg-white/85">
<Link
href={`/game/battle/${record.id}`}
className="min-w-0 flex-1 px-3 py-2 no-underline"
>
<div className="flex items-center gap-2 text-sm leading-6 whitespace-nowrap">
<span className={`${outcomeColor} shrink-0 font-semibold`}>
{outcomeLabel}
</span>
<span className="min-w-0 flex-1 truncate">{opponentName}</span>
<span className="text-ink-secondary shrink-0">{turns}回</span>
<span className="text-ink-secondary shrink-0 text-xs">
{battleTime}
</span>
</div>
</Link>
{onShare ? (
<button
type="button"
onClick={() => onShare(record)}
className="border-ink/10 text-ink-secondary hover:text-crimson shrink-0 border-l border-dashed px-3 text-sm transition"
aria-label={`分享${opponentName}的战绩`}
>
分享
</button>
) : null}
</div>
);
}
+5
View File
@@ -75,6 +75,11 @@ export const router = createBrowserRouter(
HydrateFallback={AppBootScreen}
>
<Route index lazy={lazyRoute(() => import('@app/routes/index/route'))} />
<Route
path="/battle-replay/:shareCode"
lazy={lazyRoute(() => import('@app/routes/battle-replay/route'))}
handle={title('公开战谱')}
/>
<Route
id={AUTH_LAYOUT_ROUTE_ID}
lazy={lazyRoute(() => import('@app/routes/auth/layout'))}
@@ -0,0 +1,103 @@
import { BattlePageLayout } from '@app/components/feature/battle/BattlePageLayout';
import { BattlePlaybackPanel } from '@app/components/feature/battle/v3/BattlePlaybackPanel';
import { useBattlePlaybackState } from '@app/components/feature/battle/v3/useBattlePlaybackState';
import { GameImmersiveLoading } from '@app/components/game-shell';
import Link from '@app/components/router/AppLink';
import type { PublicBattleShareDetailV1 } from '@shared/types/battle';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
type PublicBattleShareResponse = {
success: boolean;
data?: PublicBattleShareDetailV1;
error?: string;
};
export default function PublicBattleReplayPage() {
const { shareCode } = useParams<{ shareCode: string }>();
const [record, setRecord] = useState<PublicBattleShareDetailV1 | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const playback = useBattlePlaybackState(record?.battleResult);
useEffect(() => {
if (!shareCode) return;
const controller = new AbortController();
const load = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/battle-records/shared/${shareCode}`,
{ cache: 'no-store', signal: controller.signal },
);
const payload = (await response.json()) as PublicBattleShareResponse;
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error || '战谱不存在');
}
setRecord(payload.data);
} catch (loadError) {
if (controller.signal.aborted) return;
setError(
loadError instanceof Error ? loadError.message : '战谱加载失败',
);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
void load();
return () => controller.abort();
}, [shareCode]);
if (!shareCode) {
return (
<div className="bg-paper flex min-h-[100svh] items-center justify-center px-4 py-20">
<p className="text-ink">战谱不存在</p>
</div>
);
}
if (loading && !record) {
return (
<div className="bg-paper h-[100svh]">
<GameImmersiveLoading message="正在展开公开战谱……" />
</div>
);
}
if (!record) {
return (
<div className="bg-paper flex min-h-[100svh] items-center justify-center px-4 py-20">
<div className="border-battle-rule-strong max-w-md border border-dashed bg-[rgba(248,243,230,0.92)] px-5 py-5 text-center">
<p className="text-ink mb-2">{error || '战谱不存在'}</p>
<p className="text-ink-secondary mb-4 text-sm leading-6">
链接可能无效,或原始战绩已不存在。
</p>
<Link href="/game" className="text-ink hover:text-crimson">
[进入万界道友]
</Link>
</div>
</div>
);
}
return (
<div className="bg-paper h-[100svh] overflow-hidden">
<BattlePageLayout
title={`公开战谱 · ${playback.playerName} vs ${playback.opponentName}`}
subtitle="此战谱仅公开战斗过程,不包含双方精确属性。"
variant="immersive-battle"
loading={loading}
battleResult={record.battleResult}
>
<BattlePlaybackPanel
battleResult={record.battleResult}
playback={playback}
statusActions={[{ label: '进入游戏', href: '/game' }]}
/>
</BattlePageLayout>
</div>
);
}
@@ -1,8 +1,9 @@
import { BattlePageLayout } from '@app/components/feature/battle/BattlePageLayout';
import { BattleShareDialog } from '@app/components/feature/battle/share/BattleShareDialog';
import { BattlePlaybackPanel } from '@app/components/feature/battle/v3/BattlePlaybackPanel';
import { useBattlePlaybackState } from '@app/components/feature/battle/v3/useBattlePlaybackState';
import Link from '@app/components/router/AppLink';
import { GameImmersiveLoading } from '@app/components/game-shell';
import Link from '@app/components/router/AppLink';
import type { BattleRecordV3 as BattleRecordNative } from '@shared/types/battle';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
@@ -24,6 +25,7 @@ export default function BattleReplayPage() {
const [record, setRecord] = useState<BattleRecordRow | null>(null);
const [loading, setLoading] = useState(true);
const [shareOpen, setShareOpen] = useState(false);
const battleResult = record?.battleResult;
const playback = useBattlePlaybackState(battleResult);
@@ -58,7 +60,7 @@ export default function BattleReplayPage() {
if (!record && !loading) {
return (
<div className="flex h-full items-center justify-center px-4 py-20">
<div className="border-battle-rule-strong bg-[rgba(248,243,230,0.92)] max-w-md border border-dashed px-5 py-5 text-center">
<div className="border-battle-rule-strong max-w-md border border-dashed bg-[rgba(248,243,230,0.92)] px-5 py-5 text-center">
<p className="text-ink mb-4">未找到该战斗记录</p>
<Link
href="/game/battle/history"
@@ -82,11 +84,19 @@ export default function BattleReplayPage() {
<BattlePlaybackPanel
battleResult={battleResult}
playback={playback}
statusAction={{
label: '返回战绩',
href: '/game/battle/history',
}}
statusActions={[
{ label: '分享', onClick: () => setShareOpen(true) },
{ label: '返回战绩', href: '/game/battle/history' },
]}
/>
{battleResult ? (
<BattleShareDialog
isOpen={shareOpen}
battleRecordId={record.id}
summary={battleResult.outcome}
onClose={() => setShareOpen(false)}
/>
) : null}
</BattlePageLayout>
);
}
@@ -1,3 +1,4 @@
import { BattleShareDialog } from '@app/components/feature/battle/share/BattleShareDialog';
import Zhanji from '@app/components/func/Zhanji';
import {
GameLoadingState,
@@ -42,6 +43,7 @@ export default function BattleHistoryPage() {
const [pagination, setPagination] = useState<
BattleListResponse['pagination'] | null
>(null);
const [shareRecord, setShareRecord] = useState<BattleSummary | null>(null);
const cultivator = usePlayerSession().data?.activeCultivator;
useEffect(() => {
@@ -144,6 +146,7 @@ export default function BattleHistoryPage() {
key={r.id}
record={r}
currentCultivatorId={cultivator?.id}
onShare={() => setShareRecord(r)}
/>
))}
</InkList>
@@ -163,6 +166,14 @@ export default function BattleHistoryPage() {
下一页
</InkButton>
</div>
{shareRecord ? (
<BattleShareDialog
isOpen
battleRecordId={shareRecord.id}
summary={shareRecord}
onClose={() => setShareRecord(null)}
/>
) : null}
</GameSceneFrame>
);
}
@@ -554,10 +554,12 @@ export default function TrainingRoomPage() {
<BattlePlaybackPanel
battleResult={battleResult}
playback={playback}
statusAction={{
label: '离开练功房',
onClick: handleLeave,
}}
statusActions={[
{
label: '离开练功房',
onClick: handleLeave,
},
]}
/>
)}
+5 -4
View File
@@ -8,6 +8,7 @@ import type {
ItemLibraryPayload,
} from '@shared/lib/itemLibrary';
import type { TowerPreparedEnemy } from '@shared/lib/tower';
import type { BattleRecordV3 } from '@shared/types/battle';
import type {
AlchemyFormulaBlueprint,
AlchemyFormulaMastery,
@@ -15,7 +16,6 @@ import type {
PillFamily,
} from '@shared/types/consumable';
import type { MailAttachment } from '@shared/types/mail';
import type { BattleRecordV3 } from '@shared/types/battle';
import { sql } from 'drizzle-orm';
import {
bigint,
@@ -938,6 +938,8 @@ export const battleRecordsV3 = pgTable(
.notNull()
.default('normal'),
battleResult: jsonb('battle_result').$type<BattleRecordV3>().notNull(),
shareCode: uuid('share_code'),
sharedAt: timestamp('shared_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [
@@ -953,6 +955,7 @@ export const battleRecordsV3 = pgTable(
table.userId,
table.createdAt,
),
uniqueIndex('battle_records_v3_share_code_uidx').on(table.shareCode),
],
);
@@ -1193,9 +1196,7 @@ export const sectShopItems = pgTable(
.notNull(),
},
(table) => [
uniqueIndex('sect_shop_item_library_item_uidx').on(
table.itemLibraryItemId,
),
uniqueIndex('sect_shop_item_library_item_uidx').on(table.itemLibraryItemId),
index('sect_shop_status_sort_idx').on(
table.status,
table.sortOrder,
@@ -7,7 +7,8 @@ import type {
BattleRecordV3,
BattleRecordV3Summary,
} from '@shared/types/battle';
import { and, desc, eq, or, sql, type SQL } from 'drizzle-orm';
import { and, desc, eq, isNull, or, sql, type SQL } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
export type BattleRecordV3Row = typeof battleRecordsV3.$inferSelect;
@@ -31,6 +32,12 @@ export interface ListBattleRecordV3Result {
hasMore: boolean;
}
export interface BattleRecordV3ShareResult {
record: BattleRecordV3Row;
shareCode: string;
created: boolean;
}
const summaryFields = {
id: battleRecordsV3.id,
cultivatorId: battleRecordsV3.cultivatorId,
@@ -175,3 +182,55 @@ export async function getBattleRecordV3ByIdForCultivator(
validateBattleRecordV3(row.battleResult);
return row;
}
export async function ensureBattleRecordV3Share(
id: string,
cultivatorId: string,
q: DbExecutor = getExecutor(),
): Promise<BattleRecordV3ShareResult | null> {
const current = await getBattleRecordV3ByIdForCultivator(id, cultivatorId, q);
if (!current) return null;
if (current.shareCode) {
return { record: current, shareCode: current.shareCode, created: false };
}
const shareCode = randomUUID();
const [updated] = await q
.update(battleRecordsV3)
.set({ shareCode, sharedAt: new Date() })
.where(
and(
eq(battleRecordsV3.id, id),
isNull(battleRecordsV3.shareCode),
or(
eq(battleRecordsV3.cultivatorId, cultivatorId),
eq(battleRecordsV3.opponentCultivatorId, cultivatorId),
),
),
)
.returning();
if (updated) {
validateBattleRecordV3(updated.battleResult);
return { record: updated, shareCode, created: true };
}
const raced = await getBattleRecordV3ByIdForCultivator(id, cultivatorId, q);
return raced?.shareCode
? { record: raced, shareCode: raced.shareCode, created: false }
: null;
}
export async function getSharedBattleRecordV3ByCode(
shareCode: string,
q: DbExecutor = getExecutor(),
): Promise<BattleRecordV3Row | null> {
const [row] = await q
.select()
.from(battleRecordsV3)
.where(eq(battleRecordsV3.shareCode, shareCode))
.limit(1);
if (!row?.shareCode) return null;
validateBattleRecordV3(row.battleResult);
return row;
}
@@ -1,12 +1,14 @@
import { ensureBattleRecordV3Share } from '@server/lib/repositories/battleRecordV3Repository';
import * as creationProductRepository from '@server/lib/repositories/creationProductRepository';
import { readCultivatorPublicIdentity } from '@server/lib/services/cultivator/CultivatorFactsReader';
import {
getCultivatorConsumableById,
getCultivatorMaterialById,
} from '@server/lib/services/cultivator/CultivatorInventoryRepository';
import { readCultivatorPublicIdentity } from '@server/lib/services/cultivator/CultivatorFactsReader';
import type { WorldChatCreateMessageRequest } from '@shared/contracts/world-chat';
import type {
ItemShowcaseSnapshotMap,
WorldChatBattleShowcasePayload,
WorldChatItemShowcasePayload,
WorldChatMessageChannel,
WorldChatMessageDTO,
@@ -114,6 +116,28 @@ async function buildItemShowcasePayload(params: {
return { itemType, itemId, snapshot, text: showcaseText };
}
async function buildBattleShowcasePayload(params: {
cultivatorId: string;
battleRecordId: string;
text?: string;
}): Promise<WorldChatBattleShowcasePayload | null> {
const result = await ensureBattleRecordV3Share(
params.battleRecordId,
params.cultivatorId,
);
if (!result) return null;
const { outcome } = result.record.battleResult;
return {
shareCode: result.shareCode,
winner: outcome.winner,
loser: outcome.loser,
turns: outcome.turns,
battleCreatedAt: result.record.createdAt.toISOString(),
...(params.text?.trim() ? { text: params.text.trim() } : {}),
};
}
export async function createCultivatorChatMessage(params: {
request: WorldChatCreateMessageRequest;
userId: string;
@@ -164,10 +188,7 @@ export async function createCultivatorChatMessage(params: {
const text = normalizeText(params.request);
const textLength = countChars(text);
if (textLength < 1 || textLength > 100) {
throw new ChatMessageApplicationError(
'消息长度需在 1-100 字之间',
400,
);
throw new ChatMessageApplicationError('消息长度需在 1-100 字之间', 400);
}
return params.persist({
...senderBase,
@@ -177,6 +198,27 @@ export async function createCultivatorChatMessage(params: {
});
}
if (params.request.messageType === 'battle_showcase') {
const showcaseText = params.request.textContent?.trim() ?? '';
if (countChars(showcaseText) > 100) {
throw new ChatMessageApplicationError('附言长度需在 100 字以内', 400);
}
const payload = await buildBattleShowcasePayload({
cultivatorId: params.cultivatorId,
battleRecordId: params.request.battleRecordId,
text: showcaseText,
});
if (!payload) {
throw new ChatMessageApplicationError('战绩不存在或无权展示', 404);
}
return params.persist({
...senderBase,
messageType: 'battle_showcase',
textContent: payload.text,
payload,
});
}
const showcaseText = (
params.request.textContent ??
params.request.payload?.text ??
@@ -192,10 +234,7 @@ export async function createCultivatorChatMessage(params: {
text: showcaseText,
});
if (!payload) {
throw new ChatMessageApplicationError(
'道具不存在或不属于当前角色',
404,
);
throw new ChatMessageApplicationError('道具不存在或不属于当前角色', 404);
}
return params.persist({
...senderBase,
@@ -5,13 +5,17 @@ import {
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import {
ensureBattleRecordV3Share,
getBattleRecordV3ByIdForCultivator,
getSharedBattleRecordV3ByCode,
listBattleRecordV3Summaries,
} from '@server/lib/repositories/battleRecordV3Repository';
import { toPublicBattleReplayV1 } from '@shared/lib/battle/publicBattleReplay';
import { Hono } from 'hono';
import { z } from 'zod';
const router = new Hono<AppEnv>();
const UuidSchema = z.string().uuid();
const BattleRecordListQuerySchema = z.object({
page: z.coerce.number().int().min(1).max(500).default(1),
@@ -21,6 +25,29 @@ const BattleRecordListQuerySchema = z.object({
type BattleRecordListQuery = z.infer<typeof BattleRecordListQuerySchema>;
router.get('/shared/:shareCode', async (c) => {
const parsedCode = UuidSchema.safeParse(c.req.param('shareCode'));
if (!parsedCode.success) {
return c.json({ success: false, error: '记录不存在' }, 404);
}
const record = await getSharedBattleRecordV3ByCode(parsedCode.data);
if (!record?.shareCode) {
return c.json({ success: false, error: '记录不存在' }, 404);
}
return c.json({
success: true,
data: {
shareCode: record.shareCode,
createdAt: record.createdAt.toISOString(),
winner: record.battleResult.outcome.winner,
loser: record.battleResult.outcome.loser,
turns: record.battleResult.outcome.turns,
battleResult: toPublicBattleReplayV1(record.battleResult),
},
});
});
router.get(
'/v3',
requireActiveCultivatorRef(),
@@ -75,4 +102,28 @@ router.get('/v3/:id', requireActiveCultivatorRef(), async (c) => {
});
});
router.post('/v3/:id/share', requireActiveCultivatorRef(), async (c) => {
const activeRef = c.get('activeCultivatorRef');
const parsedId = UuidSchema.safeParse(c.req.param('id'));
if (!activeRef || !parsedId.success) {
return c.json({ success: false, error: '记录不存在' }, 404);
}
const result = await ensureBattleRecordV3Share(
parsedId.data,
activeRef.cultivatorId,
);
if (!result) {
return c.json({ success: false, error: '记录不存在' }, 404);
}
return c.json({
success: true,
data: {
shareCode: result.shareCode,
sharePath: `/battle-replay/${result.shareCode}`,
created: result.created,
},
});
});
export default router;
+16 -1
View File
@@ -22,9 +22,24 @@ export const WorldChatItemShowcaseMessageSchema = z.object({
.optional(),
});
export const WorldChatBattleShowcaseMessageSchema = z.object({
messageType: z.literal('battle_showcase'),
battleRecordId: z.string().uuid(),
textContent: z
.string()
.trim()
.max(200)
.refine((value) => Array.from(value).length <= 100)
.optional(),
});
export const WorldChatCreateMessageSchema = z.discriminatedUnion(
'messageType',
[WorldChatTextMessageSchema, WorldChatItemShowcaseMessageSchema],
[
WorldChatTextMessageSchema,
WorldChatItemShowcaseMessageSchema,
WorldChatBattleShowcaseMessageSchema,
],
);
export const WorldChatListQuerySchema = z.object({
@@ -0,0 +1,135 @@
import type {
AttrsStateView,
UnitStateSnapshot,
} from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecordV3 } from '@shared/types/battle';
import { toPublicBattleReplayV1 } from './publicBattleReplay';
const attrs: AttrsStateView = {
attributeModelVersion: 2,
vitality: 10,
strength: 11,
spirit: 12,
endurance: 13,
speed: 14,
willpower: 15,
atk: 100,
def: 80,
magicAtk: 90,
magicDef: 70,
actionSpeed: 14,
critRate: 0.1,
critDamageMult: 1.5,
evasionRate: 0.05,
controlHit: 0.1,
controlResistance: 0.1,
armorPenetration: 0,
magicPenetration: 0,
critResist: 0,
critDamageReduction: 0,
accuracy: 1,
healAmplify: 0,
maxHp: 1000,
maxMp: 500,
};
function snapshot(id: string, name: string): UnitStateSnapshot {
return {
id,
name,
alive: true,
hp: { current: 900, max: 1000, percent: 90 },
mp: { current: 400, max: 500, percent: 80 },
shield: 20,
attrs: { ...attrs },
baseAttrs: { ...attrs, atk: 80 },
buffs: [],
combatResources: [],
cooldowns: [],
canAct: true,
};
}
function createRecord(): BattleRecordV3 {
const player = snapshot('player', '青玄子');
const opponent = snapshot('opponent', '赤霄真人');
return {
participants: {
player: { id: player.id, name: player.name },
opponent: { id: opponent.id, name: opponent.name },
},
outcome: {
winner: { id: player.id, name: player.name },
loser: { id: opponent.id, name: opponent.name },
turns: 1,
},
sequences: [
{
id: 'sequence-1',
turn: 1,
phase: 'battle_init',
facts: [],
},
],
stateTimeline: {
unitIds: [player.id, opponent.id],
unitNames: {
[player.id]: player.name,
[opponent.id]: opponent.name,
},
frames: [
{
frameId: 1,
turn: 1,
phase: 'battle_init',
sourceSequenceId: 'sequence-1',
units: { [player.id]: player, [opponent.id]: opponent },
deltas: {
[player.id]: {
id: player.id,
name: player.name,
hp: { from: 1000, to: 900, change: -100 },
attrs: { atk: { from: 80, to: 100 } },
baseAttrs: { atk: { from: 80, to: 80 } },
},
},
},
],
},
finalSnapshots: { winner: player, loser: opponent },
};
}
describe('toPublicBattleReplayV1', () => {
it('removes exact attributes from every snapshot and delta', () => {
const record = createRecord();
const projected = toPublicBattleReplayV1(record);
for (const frame of projected.stateTimeline.frames) {
for (const unit of Object.values(frame.units)) {
expect(unit).not.toHaveProperty('attrs');
expect(unit).not.toHaveProperty('baseAttrs');
}
for (const delta of Object.values(frame.deltas ?? {})) {
expect(delta).not.toHaveProperty('attrs');
expect(delta).not.toHaveProperty('baseAttrs');
}
}
expect(projected.finalSnapshots.winner).not.toHaveProperty('attrs');
expect(projected.finalSnapshots.loser).not.toHaveProperty('baseAttrs');
expect(JSON.stringify(projected)).not.toContain('"attrs"');
expect(JSON.stringify(projected)).not.toContain('"baseAttrs"');
});
it('preserves the battle process without mutating the source record', () => {
const record = createRecord();
const before = structuredClone(record);
const projected = toPublicBattleReplayV1(record);
expect(projected.participants).toEqual(record.participants);
expect(projected.outcome).toEqual(record.outcome);
expect(projected.sequences).toEqual(record.sequences);
expect(record).toEqual(before);
});
});
@@ -0,0 +1,60 @@
import type {
UnitStateDelta,
UnitStateSnapshot,
} from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecordV3 } from '@shared/engine/battle-v5/v3';
import type {
PublicBattleReplayV1,
PublicBattleUnitDeltaV1,
PublicBattleUnitSnapshotV1,
} from '@shared/types/battle';
function mapValues<TInput, TOutput>(
values: Record<string, TInput>,
project: (value: TInput) => TOutput,
): Record<string, TOutput> {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, project(value)]),
);
}
function projectSnapshot(
snapshot: UnitStateSnapshot,
): PublicBattleUnitSnapshotV1 {
const publicSnapshot: Partial<UnitStateSnapshot> = { ...snapshot };
delete publicSnapshot.attrs;
delete publicSnapshot.baseAttrs;
return publicSnapshot as PublicBattleUnitSnapshotV1;
}
function projectDelta(delta: UnitStateDelta): PublicBattleUnitDeltaV1 {
const publicDelta: Partial<UnitStateDelta> = { ...delta };
delete publicDelta.attrs;
delete publicDelta.baseAttrs;
return publicDelta as PublicBattleUnitDeltaV1;
}
export function toPublicBattleReplayV1(
record: BattleRecordV3,
): PublicBattleReplayV1 {
return {
participants: record.participants,
outcome: record.outcome,
sequences: record.sequences,
stateTimeline: {
unitIds: record.stateTimeline.unitIds,
unitNames: record.stateTimeline.unitNames,
frames: record.stateTimeline.frames.map((frame) => ({
...frame,
units: mapValues(frame.units, projectSnapshot),
...(frame.deltas
? { deltas: mapValues(frame.deltas, projectDelta) }
: {}),
})),
},
finalSnapshots: {
winner: projectSnapshot(record.finalSnapshots.winner),
loser: projectSnapshot(record.finalSnapshots.loser),
},
};
}
+49
View File
@@ -4,6 +4,10 @@ import type {
PersistentCombatStatusV5,
ResourcePointState,
} from '@shared/engine/battle-v5/setup/types';
import type {
UnitStateDelta,
UnitStateSnapshot,
} from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecordV3 } from '@shared/engine/battle-v5/v3';
import type { Cultivator } from '@shared/types/cultivator';
@@ -35,3 +39,48 @@ export interface BattleRecordV3Detail {
createdAt: Date | null;
battleResult: BattleRecordV3;
}
export type PublicBattleUnitSnapshotV1 = Omit<
UnitStateSnapshot,
'attrs' | 'baseAttrs'
>;
export type PublicBattleUnitDeltaV1 = Omit<
UnitStateDelta,
'attrs' | 'baseAttrs'
>;
export interface PublicBattleStateFrameV1 extends Omit<
BattleRecordV3['stateTimeline']['frames'][number],
'units' | 'deltas'
> {
units: Record<string, PublicBattleUnitSnapshotV1>;
deltas?: Record<string, PublicBattleUnitDeltaV1>;
}
/** 战斗播放器所需的最小数据,完整记录与公开投影都满足此结构。 */
export interface BattlePlaybackRecordV3 {
participants: BattleRecordV3['participants'];
outcome: BattleRecordV3['outcome'];
sequences: BattleRecordV3['sequences'];
stateTimeline: Omit<BattleRecordV3['stateTimeline'], 'frames'> & {
frames: PublicBattleStateFrameV1[];
};
}
/** 免登录分享接口返回的战斗过程,不含精确基础或派生属性。 */
export interface PublicBattleReplayV1 extends BattlePlaybackRecordV3 {
finalSnapshots: {
winner: PublicBattleUnitSnapshotV1;
loser: PublicBattleUnitSnapshotV1;
};
}
export interface PublicBattleShareDetailV1 {
shareCode: string;
createdAt: string;
winner: BattleRecordUnitSummary;
loser: BattleRecordUnitSummary;
turns: number;
battleResult: PublicBattleReplayV1;
}
+14 -6
View File
@@ -1,3 +1,4 @@
import type { BattleRecordUnitSummary } from './battle';
import type { ElementType, Quality } from './constants';
import type { Artifact, Consumable, Material } from './cultivator';
@@ -5,7 +6,8 @@ export type WorldChatMessageChannel = 'system' | 'world' | 'sect';
export type WorldChatChannel = WorldChatMessageChannel;
export type WorldChatMessageType = 'text' | 'duel_invite' | 'item_showcase';
export type WorldChatMessageType =
'text' | 'duel_invite' | 'item_showcase' | 'battle_showcase';
export interface WorldChatTextPayload {
text: string;
@@ -23,11 +25,7 @@ export interface WorldChatDuelInvitePayload {
}
export type WorldChatShowcaseItemType =
| 'artifact'
| 'material'
| 'consumable'
| 'skill'
| 'gongfa';
'artifact' | 'material' | 'consumable' | 'skill' | 'gongfa';
export type ItemShowcaseSnapshotMap = {
artifact: Pick<
@@ -80,10 +78,20 @@ export interface WorldChatItemShowcasePayload {
text?: string;
}
export interface WorldChatBattleShowcasePayload {
shareCode: string;
winner: BattleRecordUnitSummary;
loser: BattleRecordUnitSummary;
turns: number;
battleCreatedAt: string;
text?: string;
}
export interface WorldChatPayloadMap {
text: WorldChatTextPayload;
duel_invite: WorldChatDuelInvitePayload;
item_showcase: WorldChatItemShowcasePayload;
battle_showcase: WorldChatBattleShowcasePayload;
}
export type WorldChatPayload = WorldChatPayloadMap[WorldChatMessageType];