功能:战绩分享
This commit is contained in:
1 parent
b686347556
commit
109ee817ec
28 files changed
+7994
-101
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user