add long objective setting func

This commit is contained in:
bridge
2025-11-21 22:14:27 +08:00
parent 327015fdea
commit a234e621b7
7 changed files with 289 additions and 16 deletions

View File

@@ -177,3 +177,15 @@ def set_user_long_term_objective(avatar: "Avatar", objective_content: str) -> No
) )
logger.info(f"玩家为角色 {avatar.name} 设定长期目标:{objective_content}") logger.info(f"玩家为角色 {avatar.name} 设定长期目标:{objective_content}")
def clear_user_long_term_objective(avatar: "Avatar") -> bool:
"""
清空玩家设定的长期目标
如果当前目标是 system/llm 生成的,则不清除并返回 False
如果是 user 生成的,清除并返回 True
"""
if avatar.long_term_objective and avatar.long_term_objective.origin == "user":
avatar.long_term_objective = None
logger.info(f"玩家清空了角色 {avatar.name} 的长期目标")
return True
return False

View File

@@ -7,6 +7,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Quer
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import uvicorn import uvicorn
from pydantic import BaseModel
# 确保可以导入 src 模块 # 确保可以导入 src 模块
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
@@ -20,6 +21,7 @@ from src.utils.config import CONFIG
from src.classes.sect import sects_by_id from src.classes.sect import sects_by_id
from src.classes.color import serialize_hover_lines from src.classes.color import serialize_hover_lines
from src.classes.event import Event from src.classes.event import Event
from src.classes.long_term_objective import set_user_long_term_objective, clear_user_long_term_objective
import random import random
# 全局游戏实例 # 全局游戏实例
@@ -71,7 +73,6 @@ def serialize_events_for_client(events: List[Event]) -> List[dict]:
year = None year = None
try: try:
month_obj = month_stamp.get_month() month_obj = month_stamp.get_month()
month = int(getattr(month_obj, "value", month_obj))
except Exception: except Exception:
month = None month = None
@@ -374,6 +375,42 @@ def get_hover_info(
"lines": serialize_hover_lines([str(line) for line in lines]), "lines": serialize_hover_lines([str(line) for line in lines]),
} }
class SetObjectiveRequest(BaseModel):
avatar_id: str
content: str
class ClearObjectiveRequest(BaseModel):
avatar_id: str
@app.post("/api/action/set_long_term_objective")
def set_long_term_objective(req: SetObjectiveRequest):
world = game_instance.get("world")
if not world:
raise HTTPException(status_code=503, detail="World not initialized")
avatar = world.avatar_manager.avatars.get(req.avatar_id)
if not avatar:
raise HTTPException(status_code=404, detail="Avatar not found")
set_user_long_term_objective(avatar, req.content)
return {"status": "ok", "message": "Objective set"}
@app.post("/api/action/clear_long_term_objective")
def clear_long_term_objective(req: ClearObjectiveRequest):
world = game_instance.get("world")
if not world:
raise HTTPException(status_code=503, detail="World not initialized")
avatar = world.avatar_manager.avatars.get(req.avatar_id)
if not avatar:
raise HTTPException(status_code=404, detail="Avatar not found")
cleared = clear_user_long_term_objective(avatar)
return {
"status": "ok",
"message": "Objective cleared" if cleared else "No user objective to clear"
}
def start(): def start():
"""启动服务的入口函数""" """启动服务的入口函数"""
# 改为 8002 端口 # 改为 8002 端口

View File

@@ -20,6 +20,7 @@
要求与约束: 要求与约束:
- thought从侧面体现出角色特质、宗门信息等 - thought从侧面体现出角色特质、宗门信息等
- 长期目标是非常重要的一个参数,其权重最高
- 执行动作只能从给定的全部动作中选且需满足对应条件见动作的requirements文本 - 执行动作只能从给定的全部动作中选且需满足对应条件见动作的requirements文本
- 一些动作需要先移动满足某些条件才可执行,可以适当规划。 - 一些动作需要先移动满足某些条件才可执行,可以适当规划。
- 和另一个角色交互的动作必须在对应角色附近。执行前可以先MoveToAvatar - 和另一个角色交互的动作必须在对应角色附近。执行前可以先MoveToAvatar

View File

@@ -6,11 +6,16 @@ const store = useGameStore()
const panelRef = ref<HTMLElement | null>(null) const panelRef = ref<HTMLElement | null>(null)
let lastOpenAt = 0 let lastOpenAt = 0
const showObjectiveModal = ref(false)
const objectiveContent = ref('')
const title = computed(() => store.selectedTarget?.name ?? '') const title = computed(() => store.selectedTarget?.name ?? '')
watch( watch(
() => store.selectedTarget, () => store.selectedTarget,
(target) => { (target) => {
showObjectiveModal.value = false
objectiveContent.value = ''
if (target) { if (target) {
lastOpenAt = performance.now() lastOpenAt = performance.now()
} }
@@ -29,6 +34,20 @@ function handleDocumentPointerDown(event: PointerEvent) {
store.closeInfoPanel() store.closeInfoPanel()
} }
async function handleSetObjective() {
if (!store.selectedTarget || !objectiveContent.value.trim()) return
await store.setLongTermObjective(store.selectedTarget.id, objectiveContent.value)
showObjectiveModal.value = false
objectiveContent.value = ''
}
async function handleClearObjective() {
if (!store.selectedTarget) return
if (confirm('确定要清空该角色的长期目标吗?(系统将在之后自动重新生成)')) {
await store.clearLongTermObjective(store.selectedTarget.id)
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown) document.addEventListener('pointerdown', handleDocumentPointerDown)
}) })
@@ -44,6 +63,11 @@ onUnmounted(() => {
class="info-panel" class="info-panel"
ref="panelRef" ref="panelRef"
> >
<div class="panel-actions" v-if="store.selectedTarget.type === 'avatar'">
<button class="action-btn primary" @click="showObjectiveModal = true">设定长期目标</button>
<button class="action-btn secondary" @click="handleClearObjective">清空长期目标</button>
</div>
<div class="info-header"> <div class="info-header">
<div class="info-title">{{ title || '详情' }}</div> <div class="info-title">{{ title || '详情' }}</div>
<button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button> <button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button>
@@ -70,6 +94,20 @@ onUnmounted(() => {
</ul> </ul>
<div v-else class="placeholder">暂无信息</div> <div v-else class="placeholder">暂无信息</div>
</div> </div>
<!-- 长期目标设定弹窗 -->
<div v-if="showObjectiveModal" class="objective-modal">
<div class="modal-title">设定长期目标</div>
<textarea
v-model="objectiveContent"
placeholder="请输入该角色未来3-5年的长期目标..."
class="objective-input"
></textarea>
<div class="modal-actions">
<button class="modal-btn confirm" @click="handleSetObjective" :disabled="!objectiveContent.trim()">确认</button>
<button class="modal-btn cancel" @click="showObjectiveModal = false">取消</button>
</div>
</div>
</div> </div>
</template> </template>
@@ -88,7 +126,51 @@ onUnmounted(() => {
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: visible; /* Allow modal to show outside */
}
.panel-actions {
display: flex;
flex-direction: column;
padding: 12px 14px 4px;
gap: 6px;
background: rgba(38, 38, 38, 0.95);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.action-btn {
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.action-btn.primary {
background: #177ddc;
color: white;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
}
.action-btn.primary:hover {
background: #1890ff;
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #aaa;
padding: 5px 12px;
font-size: 12px;
}
.action-btn.secondary:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
color: #ccc;
} }
.info-header { .info-header {
@@ -123,6 +205,9 @@ onUnmounted(() => {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 16px; padding: 12px 16px;
/* 确保body内容滚动时圆角 */
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
} }
.info-list { .info-list {
@@ -149,5 +234,86 @@ onUnmounted(() => {
.placeholder.error { .placeholder.error {
color: #ff7875; color: #ff7875;
} }
</style>
/* Modal Styles */
.objective-modal {
position: absolute;
top: 0;
right: 100%; /* Position to the left of the panel */
margin-right: 12px;
width: 280px;
background: rgba(32, 32, 32, 0.98);
border: 1px solid #444;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 12px;
z-index: 100;
}
.modal-title {
font-size: 14px;
font-weight: bold;
color: #ddd;
}
.objective-input {
width: 100%;
height: 120px;
background: #1f1f1f;
border: 1px solid #444;
border-radius: 4px;
color: #eee;
padding: 8px;
resize: none;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
outline: none;
}
.objective-input:focus {
border-color: #177ddc;
}
.modal-actions {
display: flex;
gap: 10px;
}
.modal-btn {
flex: 1;
padding: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: opacity 0.2s;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-btn.confirm {
background: #177ddc;
color: white;
}
.modal-btn.confirm:hover:not(:disabled) {
background: #1890ff;
}
.modal-btn.cancel {
background: #444;
color: #bbb;
}
.modal-btn.cancel:hover {
background: #555;
color: white;
}
</style>

View File

@@ -19,3 +19,28 @@ export async function apiGet<T>(url: string, options: ApiRequestOptions = {}): P
window.clearTimeout(timer) window.clearTimeout(timer)
} }
} }
export async function apiPost<T>(url: string, body: any, options: ApiRequestOptions = {}): Promise<T> {
const { timeout = DEFAULT_TIMEOUT, headers, ...init } = options
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(body),
signal: controller.signal,
...init
})
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
return (await response.json()) as T
} finally {
window.clearTimeout(timer)
}
}

View File

@@ -4,7 +4,7 @@ import type {
InitialStateResponse, InitialStateResponse,
MapResponse MapResponse
} from '../types/game' } from '../types/game'
import { apiGet } from './apiClient' import { apiGet, apiPost } from './apiClient'
function buildHoverQuery(target: HoverTarget) { function buildHoverQuery(target: HoverTarget) {
const query = new URLSearchParams({ const query = new URLSearchParams({
@@ -25,6 +25,18 @@ export const gameApi = {
getMap() { getMap() {
return apiGet<MapResponse>('/api/map') return apiGet<MapResponse>('/api/map')
},
setLongTermObjective(avatarId: string, content: string) {
return apiPost<{ status: string; message: string }>('/api/action/set_long_term_objective', {
avatar_id: avatarId,
content
})
},
clearLongTermObjective(avatarId: string) {
return apiPost<{ status: string; message: string }>('/api/action/clear_long_term_objective', {
avatar_id: avatarId
})
} }
} }

View File

@@ -111,21 +111,23 @@ export const useGameStore = defineStore('game', () => {
} }
} }
async function fetchHoverInfo(target: HoverTarget) { async function fetchHoverInfo(target: HoverTarget, forceRefresh = false) {
const key = cacheKey(target) const key = cacheKey(target)
const cached = hoverCache.get(key) if (!forceRefresh) {
if (cached) { const cached = hoverCache.get(key)
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) { if (cached) {
hoverInfo.value = cached if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
hoverInfo.value = cached
}
infoLoading.value = false
infoError.value = null
return
} }
infoLoading.value = false
infoError.value = null
return
} }
infoLoading.value = true infoLoading.value = true
infoError.value = null infoError.value = null
hoverInfo.value = [] if (!forceRefresh) hoverInfo.value = []
try { try {
const data = await gameApi.getHoverInfo(target) const data = await gameApi.getHoverInfo(target)
@@ -146,6 +148,22 @@ export const useGameStore = defineStore('game', () => {
} }
} }
async function setLongTermObjective(avatarId: string, content: string) {
await gameApi.setLongTermObjective(avatarId, content)
// 成功后刷新 info panel
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
await fetchHoverInfo(selectedTarget.value, true)
}
}
async function clearLongTermObjective(avatarId: string) {
await gameApi.clearLongTermObjective(avatarId)
// 成功后刷新 info panel
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
await fetchHoverInfo(selectedTarget.value, true)
}
}
function connect() { function connect() {
gateway.connect() gateway.connect()
} }
@@ -181,6 +199,8 @@ export const useGameStore = defineStore('game', () => {
disconnect, disconnect,
fetchInitialState, fetchInitialState,
openInfoPanel, openInfoPanel,
closeInfoPanel closeInfoPanel,
setLongTermObjective,
clearLongTermObjective
} }
}) })