feat: 实现响应候选模型与私有动作消息结构化

新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括:

- 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型
- 扩展 PrivateActionMessage 支持响应候选上下文
- 实现 ResponseActionWindowBuilder 构建弃牌响应候选
- 拆分 GameMessagePublisher 支持回合动作和响应动作消息
- 更新前端原型页展示结构化候选动作
- 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md
This commit is contained in:
hujun
2026-03-20 13:04:59 +08:00
parent 24fce055fd
commit 48da7d4990
20 changed files with 1151 additions and 208 deletions

View File

@@ -63,11 +63,22 @@ type PublicGameMessage = {
createdAt: string
}
type PrivateActionCandidate = {
actionType: string
tile: string | null
}
type PrivateActionMessage = {
gameId: string
userId: string
actionScope: string
availableActions: string[]
currentSeatNo: number
windowId: string | null
triggerEventType: string | null
sourceSeatNo: number | null
triggerTile: string | null
candidates: PrivateActionCandidate[]
}
type PrivateTeachingMessage = {
@@ -107,11 +118,24 @@ const phaseLabelMap: Record<string, string> = {
LACK_SELECTION: '定缺阶段'
}
const actionScopeLabelMap: Record<string, string> = {
TURN: '当前回合动作',
RESPONSE: '响应候选动作'
}
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed(
() => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo
)
const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
const privateActionSummary = computed(() => {
if (!privateAction.value) {
return ''
}
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
return `${scopeLabel}${privateAction.value.availableActions.join(' / ')}`
})
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -516,7 +540,27 @@ function discard(tile: string) {
<div class="message-stack">
<div class="message-card">
<span class="meta-label">私有动作消息</span>
<strong v-if="privateAction">{{ privateAction.availableActions.join(' / ') }}</strong>
<template v-if="privateAction">
<strong>{{ privateActionSummary }}</strong>
<div class="mini-tags action-meta-row">
<span class="mini-tag">座位 {{ privateAction.currentSeatNo }}</span>
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span>
<span v-if="privateAction.triggerTile" class="mini-tag">目标牌 {{ privateAction.triggerTile }}</span>
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
</div>
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
<span
v-for="(candidate, index) in privateActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip"
>
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
</span>
</div>
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
当前原型页已能识别响应候选消息后续会继续补正式动作面板和真实响应流程
</span>
</template>
<span v-else class="empty-copy">尚未收到私有动作消息</span>
</div>
<div class="message-card">

View File

@@ -333,6 +333,31 @@ h2 {
word-break: break-word;
}
.action-meta-row {
margin-top: 10px;
}
.candidate-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.candidate-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(113, 82, 47, 0.18);
background: linear-gradient(180deg, rgba(255, 247, 235, 0.98) 0%, rgba(241, 224, 196, 0.92) 100%);
color: var(--text);
font-size: 13px;
font-weight: 800;
}
.compact-field {
flex: 1;
min-width: 110px;