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}")
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.staticfiles import StaticFiles
import uvicorn
from pydantic import BaseModel
# 确保可以导入 src 模块
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.color import serialize_hover_lines
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
# 全局游戏实例
@@ -71,7 +73,6 @@ def serialize_events_for_client(events: List[Event]) -> List[dict]:
year = None
try:
month_obj = month_stamp.get_month()
month = int(getattr(month_obj, "value", month_obj))
except Exception:
month = None
@@ -374,6 +375,42 @@ def get_hover_info(
"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():
"""启动服务的入口函数"""
# 改为 8002 端口

View File

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

View File

@@ -6,11 +6,16 @@ const store = useGameStore()
const panelRef = ref<HTMLElement | null>(null)
let lastOpenAt = 0
const showObjectiveModal = ref(false)
const objectiveContent = ref('')
const title = computed(() => store.selectedTarget?.name ?? '')
watch(
() => store.selectedTarget,
(target) => {
showObjectiveModal.value = false
objectiveContent.value = ''
if (target) {
lastOpenAt = performance.now()
}
@@ -29,6 +34,20 @@ function handleDocumentPointerDown(event: PointerEvent) {
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(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
@@ -44,6 +63,11 @@ onUnmounted(() => {
class="info-panel"
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-title">{{ title || '详情' }}</div>
<button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button>
@@ -70,6 +94,20 @@ onUnmounted(() => {
</ul>
<div v-else class="placeholder">暂无信息</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>
</template>
@@ -88,7 +126,51 @@ onUnmounted(() => {
pointer-events: auto;
display: flex;
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 {
@@ -123,6 +205,9 @@ onUnmounted(() => {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
/* 确保body内容滚动时圆角 */
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.info-list {
@@ -149,5 +234,86 @@ onUnmounted(() => {
.placeholder.error {
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)
}
}
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,
MapResponse
} from '../types/game'
import { apiGet } from './apiClient'
import { apiGet, apiPost } from './apiClient'
function buildHoverQuery(target: HoverTarget) {
const query = new URLSearchParams({
@@ -25,6 +25,18 @@ export const gameApi = {
getMap() {
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 cached = hoverCache.get(key)
if (cached) {
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
hoverInfo.value = cached
if (!forceRefresh) {
const cached = hoverCache.get(key)
if (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
infoError.value = null
hoverInfo.value = []
if (!forceRefresh) hoverInfo.value = []
try {
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() {
gateway.connect()
}
@@ -181,6 +199,8 @@ export const useGameStore = defineStore('game', () => {
disconnect,
fetchInitialState,
openInfoPanel,
closeInfoPanel
closeInfoPanel,
setLongTermObjective,
clearLongTermObjective
}
})
})