Spaces:
Sleeping
Sleeping
feat(block19/3): goal proposal flow — propose_goal action + confirm/reject UI
Browse files- app/agents/prompts.py +27 -0
- app/schemas/agents.py +5 -2
- app/sockets/room_server.py +309 -0
- app/web/templates/agent_room.html +144 -2
app/agents/prompts.py
CHANGED
|
@@ -522,5 +522,32 @@ is hopeless. Compose a final line that fits your personality. Be in character
|
|
| 522 |
some agents are graceful, some salty, some philosophical. No specific move claims.
|
| 523 |
Keep it 1-2 sentences max.
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
Respond ONLY with a JSON object matching the requested schema. No preamble, no markdown fences.
|
| 526 |
"""
|
|
|
|
| 522 |
some agents are graceful, some salty, some philosophical. No specific move claims.
|
| 523 |
Keep it 1-2 sentences max.
|
| 524 |
|
| 525 |
+
=== GOAL PROPOSAL ===
|
| 526 |
+
|
| 527 |
+
If your owner asks you to do something autonomous — run a series of matches, win money,
|
| 528 |
+
achieve a financial target ("triple my money", "make 50 $CLAY") — emit:
|
| 529 |
+
|
| 530 |
+
game_action: "propose_goal"
|
| 531 |
+
|
| 532 |
+
When you emit "propose_goal", the system computes concrete parameters (stake size, max
|
| 533 |
+
loss, match count, profit target) and YOU describe the plan to the owner for their
|
| 534 |
+
confirmation. Your description MUST include ALL of the following, in your character's voice:
|
| 535 |
+
- How many matches you'll play (max)
|
| 536 |
+
- What stake you'll use per match
|
| 537 |
+
- Total maximum loss (so they understand the risk)
|
| 538 |
+
- What "success" means (target profit if any)
|
| 539 |
+
- That you'll report back after each match and they can stop you at any time
|
| 540 |
+
|
| 541 |
+
The NUMBERS in your description must match the system's plan exactly — don't invent
|
| 542 |
+
stakes or targets the system didn't calculate. The system will give you the numbers
|
| 543 |
+
in a [GOAL PLAN] block in the user prompt; your job is to say them in character and ask
|
| 544 |
+
for confirmation.
|
| 545 |
+
|
| 546 |
+
Use "propose_goal" ONLY when the owner is asking for an AUTONOMOUS RUN of multiple
|
| 547 |
+
matches or a money goal — NOT for a single ad-hoc game (use "start_match_vs_kenji" for
|
| 548 |
+
that). The owner must confirm before any autonomous loop starts.
|
| 549 |
+
|
| 550 |
+
=== END GOAL PROPOSAL ===
|
| 551 |
+
|
| 552 |
Respond ONLY with a JSON object matching the requested schema. No preamble, no markdown fences.
|
| 553 |
"""
|
app/schemas/agents.py
CHANGED
|
@@ -116,7 +116,7 @@ class SoulResponse(BaseModel):
|
|
| 116 |
description="IDs (from the surfaced memories shown in the prompt) that shaped "
|
| 117 |
"this response. Empty if no memory was relevant.",
|
| 118 |
)
|
| 119 |
-
game_action: Literal["none", "propose_game", "start_game", "start_match_vs_kenji"] = Field(
|
| 120 |
default="none",
|
| 121 |
description=(
|
| 122 |
"Gameplay intent. In a running match always 'none'.\n"
|
|
@@ -124,7 +124,10 @@ class SoulResponse(BaseModel):
|
|
| 124 |
"'start_game' (player EXPLICITLY asked to play NOW — 'let's play', 'I'm ready', etc.).\n"
|
| 125 |
"Agent pre-match room: 'none' (keep chatting), "
|
| 126 |
"'start_match_vs_kenji' (owner EXPLICITLY ordered a match vs Kenji NOW — "
|
| 127 |
-
"'go play Kenji', 'challenge Kenji', 'start the match', etc.)
|
|
|
|
|
|
|
|
|
|
| 128 |
"Do NOT use start_game / start_match_vs_kenji for questions, small talk, or vague interest."
|
| 129 |
),
|
| 130 |
)
|
|
|
|
| 116 |
description="IDs (from the surfaced memories shown in the prompt) that shaped "
|
| 117 |
"this response. Empty if no memory was relevant.",
|
| 118 |
)
|
| 119 |
+
game_action: Literal["none", "propose_game", "start_game", "start_match_vs_kenji", "propose_goal"] = Field(
|
| 120 |
default="none",
|
| 121 |
description=(
|
| 122 |
"Gameplay intent. In a running match always 'none'.\n"
|
|
|
|
| 124 |
"'start_game' (player EXPLICITLY asked to play NOW — 'let's play', 'I'm ready', etc.).\n"
|
| 125 |
"Agent pre-match room: 'none' (keep chatting), "
|
| 126 |
"'start_match_vs_kenji' (owner EXPLICITLY ordered a match vs Kenji NOW — "
|
| 127 |
+
"'go play Kenji', 'challenge Kenji', 'start the match', etc.),\n"
|
| 128 |
+
"'propose_goal' (owner asked you to do something autonomous — run a series of matches, "
|
| 129 |
+
"win money, etc. — and you want to propose a plan for their confirmation. "
|
| 130 |
+
"Use this when the request involves multiple matches or a financial goal, NOT for a single ad-hoc game).\n"
|
| 131 |
"Do NOT use start_game / start_match_vs_kenji for questions, small talk, or vague interest."
|
| 132 |
),
|
| 133 |
)
|
app/sockets/room_server.py
CHANGED
|
@@ -55,6 +55,11 @@ def chat_room(session_id: str) -> str:
|
|
| 55 |
# --- Event constants -------------------------------------------------------
|
| 56 |
|
| 57 |
C2S_PLAYER_CHAT = "player_chat"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
S2C_ROOM_STATE = "room_state"
|
| 59 |
S2C_PLAYER_CHAT_ACK = "player_chat_ack"
|
| 60 |
S2C_AGENT_THINKING = "agent_thinking"
|
|
@@ -64,6 +69,9 @@ S2C_GAME_STARTED = "game_started"
|
|
| 64 |
S2C_MATCH_VS_KENJI_STARTED = "match_vs_kenji_started"
|
| 65 |
S2C_GREETING_DONE = "greeting_done"
|
| 66 |
S2C_PLAYER_CHAT_RATE_LIMITED = "player_chat_rate_limited"
|
|
|
|
|
|
|
|
|
|
| 67 |
S2C_ERROR = "error"
|
| 68 |
|
| 69 |
|
|
@@ -361,6 +369,21 @@ async def _on_disconnect(sid):
|
|
| 361 |
sid, sess.get("chat_session_id"),
|
| 362 |
)
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
# --- Agent greeting (first visit only) ------------------------------------
|
| 366 |
|
|
@@ -1094,3 +1117,289 @@ async def run_agent_room_pipeline(
|
|
| 1094 |
to=sid,
|
| 1095 |
namespace=ROOM_NAMESPACE,
|
| 1096 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# --- Event constants -------------------------------------------------------
|
| 56 |
|
| 57 |
C2S_PLAYER_CHAT = "player_chat"
|
| 58 |
+
C2S_CONFIRM_GOAL = "confirm_goal"
|
| 59 |
+
C2S_REJECT_GOAL = "reject_goal"
|
| 60 |
+
C2S_PAUSE_GOAL = "pause_goal"
|
| 61 |
+
C2S_STOP_GOAL = "stop_goal"
|
| 62 |
+
C2S_RESUME_GOAL = "resume_goal"
|
| 63 |
S2C_ROOM_STATE = "room_state"
|
| 64 |
S2C_PLAYER_CHAT_ACK = "player_chat_ack"
|
| 65 |
S2C_AGENT_THINKING = "agent_thinking"
|
|
|
|
| 69 |
S2C_MATCH_VS_KENJI_STARTED = "match_vs_kenji_started"
|
| 70 |
S2C_GREETING_DONE = "greeting_done"
|
| 71 |
S2C_PLAYER_CHAT_RATE_LIMITED = "player_chat_rate_limited"
|
| 72 |
+
S2C_GOAL_PROPOSED = "goal_proposed"
|
| 73 |
+
S2C_GOAL_STATUS = "goal_status"
|
| 74 |
+
S2C_GOAL_REJECTED = "goal_rejected"
|
| 75 |
S2C_ERROR = "error"
|
| 76 |
|
| 77 |
|
|
|
|
| 369 |
sid, sess.get("chat_session_id"),
|
| 370 |
)
|
| 371 |
|
| 372 |
+
# Re-stamp last_seen_at on disconnect so live messages received while in
|
| 373 |
+
# the room don't leave a stale unread indicator afterwards.
|
| 374 |
+
if sess.get("mode") == "agent":
|
| 375 |
+
chat_session_id = sess.get("agent_chat_session_id")
|
| 376 |
+
if chat_session_id:
|
| 377 |
+
try:
|
| 378 |
+
from app.characters.agent_chat_service import mark_seen as _mark_seen
|
| 379 |
+
from app.models.agent_chat import AgentChatSession as _ACS
|
| 380 |
+
with SessionLocal() as _s:
|
| 381 |
+
cs = _s.get(_ACS, chat_session_id)
|
| 382 |
+
if cs:
|
| 383 |
+
_mark_seen(_s, cs)
|
| 384 |
+
except Exception:
|
| 385 |
+
logger.exception("Failed to mark_seen on agent room disconnect sid=%s", sid)
|
| 386 |
+
|
| 387 |
|
| 388 |
# --- Agent greeting (first visit only) ------------------------------------
|
| 389 |
|
|
|
|
| 1117 |
to=sid,
|
| 1118 |
namespace=ROOM_NAMESPACE,
|
| 1119 |
)
|
| 1120 |
+
|
| 1121 |
+
# --- Handle propose_goal ------------------------------------------------
|
| 1122 |
+
if soul_resp.game_action == "propose_goal":
|
| 1123 |
+
try:
|
| 1124 |
+
def _build_proposal():
|
| 1125 |
+
from app.economy.clay_ledger import get_ledger
|
| 1126 |
+
from app.economy.goal_planner import propose_plan
|
| 1127 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1128 |
+
from app.models.player_agent import PlayerAgent as _PA
|
| 1129 |
+
|
| 1130 |
+
with SessionLocal() as s:
|
| 1131 |
+
ag = s.get(_PA, agent_id)
|
| 1132 |
+
if ag is None:
|
| 1133 |
+
return None
|
| 1134 |
+
# Block one goal at a time.
|
| 1135 |
+
if ag.active_goal_id is not None:
|
| 1136 |
+
existing = s.get(AgentGoal, ag.active_goal_id)
|
| 1137 |
+
if existing is not None and existing.status in (
|
| 1138 |
+
GoalStatus.PROPOSED.value,
|
| 1139 |
+
GoalStatus.ACTIVE.value,
|
| 1140 |
+
GoalStatus.PAUSED.value,
|
| 1141 |
+
):
|
| 1142 |
+
return {"blocked": True, "goal_id": ag.active_goal_id, "status": existing.status}
|
| 1143 |
+
|
| 1144 |
+
balance = get_ledger().get_balance(player_id)
|
| 1145 |
+
plan = propose_plan(player_text, current_balance_cents=balance)
|
| 1146 |
+
|
| 1147 |
+
goal = AgentGoal(
|
| 1148 |
+
agent_id=agent_id,
|
| 1149 |
+
owner_id=player_id,
|
| 1150 |
+
natural_language_request=player_text,
|
| 1151 |
+
target_profit_cents=plan.target_profit_cents,
|
| 1152 |
+
max_total_loss_cents=plan.max_total_loss_cents,
|
| 1153 |
+
max_stake_per_match_cents=plan.max_stake_per_match_cents,
|
| 1154 |
+
max_matches=plan.max_matches,
|
| 1155 |
+
opponent_kind="kenji",
|
| 1156 |
+
status=GoalStatus.PROPOSED.value,
|
| 1157 |
+
starting_balance_at_goal_start_cents=balance,
|
| 1158 |
+
)
|
| 1159 |
+
s.add(goal)
|
| 1160 |
+
ag.active_goal_id = goal.id
|
| 1161 |
+
s.commit()
|
| 1162 |
+
s.refresh(goal)
|
| 1163 |
+
return {
|
| 1164 |
+
"goal_id": goal.id,
|
| 1165 |
+
"plan": {
|
| 1166 |
+
"max_matches": plan.max_matches,
|
| 1167 |
+
"max_stake_per_match_cents": plan.max_stake_per_match_cents,
|
| 1168 |
+
"max_total_loss_cents": plan.max_total_loss_cents,
|
| 1169 |
+
"target_profit_cents": plan.target_profit_cents,
|
| 1170 |
+
"summary": plan.summary,
|
| 1171 |
+
"balance_cents": balance,
|
| 1172 |
+
},
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
result = await asyncio.to_thread(_build_proposal)
|
| 1176 |
+
if result is None:
|
| 1177 |
+
raise RuntimeError("agent not found")
|
| 1178 |
+
if result.get("blocked"):
|
| 1179 |
+
await sio.emit(
|
| 1180 |
+
S2C_AGENT_ERROR,
|
| 1181 |
+
{"message": f"You already have an active goal (status: {result.get('status')}). Stop it first."},
|
| 1182 |
+
to=sid,
|
| 1183 |
+
namespace=ROOM_NAMESPACE,
|
| 1184 |
+
)
|
| 1185 |
+
else:
|
| 1186 |
+
await sio.emit(
|
| 1187 |
+
S2C_GOAL_PROPOSED,
|
| 1188 |
+
result,
|
| 1189 |
+
to=sid,
|
| 1190 |
+
namespace=ROOM_NAMESPACE,
|
| 1191 |
+
)
|
| 1192 |
+
logger.info("[room/agent] goal_proposed agent=%s goal=%s", agent_id, result.get("goal_id"))
|
| 1193 |
+
except Exception:
|
| 1194 |
+
logger.exception("[room/agent] goal proposal failed for agent=%s", agent_id)
|
| 1195 |
+
await sio.emit(
|
| 1196 |
+
S2C_AGENT_ERROR,
|
| 1197 |
+
{"message": "Failed to build the goal plan. Try again."},
|
| 1198 |
+
to=sid,
|
| 1199 |
+
namespace=ROOM_NAMESPACE,
|
| 1200 |
+
)
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
+
# --- Goal control event handlers ------------------------------------------
|
| 1204 |
+
|
| 1205 |
+
|
| 1206 |
+
async def _get_agent_session_data(sid: str) -> tuple[str | None, str | None]:
|
| 1207 |
+
"""Return (agent_id, player_id) from a session, or (None, None) if not agent mode."""
|
| 1208 |
+
try:
|
| 1209 |
+
sess = await sio.get_session(sid, namespace=ROOM_NAMESPACE)
|
| 1210 |
+
except KeyError:
|
| 1211 |
+
return None, None
|
| 1212 |
+
if sess.get("mode") != "agent":
|
| 1213 |
+
return None, None
|
| 1214 |
+
return sess.get("agent_id"), sess.get("player_id")
|
| 1215 |
+
|
| 1216 |
+
|
| 1217 |
+
@sio.on(C2S_CONFIRM_GOAL, namespace=ROOM_NAMESPACE)
|
| 1218 |
+
async def _on_confirm_goal(sid, data):
|
| 1219 |
+
agent_id, player_id = await _get_agent_session_data(sid)
|
| 1220 |
+
if not agent_id or not player_id:
|
| 1221 |
+
await _send_error(sid, "no_session", "Not in an agent room.")
|
| 1222 |
+
return
|
| 1223 |
+
|
| 1224 |
+
goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
|
| 1225 |
+
if not goal_id:
|
| 1226 |
+
await _send_error(sid, "bad_payload", "Missing goal_id.")
|
| 1227 |
+
return
|
| 1228 |
+
|
| 1229 |
+
def _activate_goal():
|
| 1230 |
+
from datetime import datetime as _dt
|
| 1231 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1232 |
+
from app.models.player_agent import PlayerAgent as _PA
|
| 1233 |
+
from app.economy.clay_ledger import get_ledger
|
| 1234 |
+
|
| 1235 |
+
with SessionLocal() as s:
|
| 1236 |
+
goal = s.get(AgentGoal, goal_id)
|
| 1237 |
+
if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
|
| 1238 |
+
return "not_found"
|
| 1239 |
+
if goal.status != GoalStatus.PROPOSED.value:
|
| 1240 |
+
return "not_proposed"
|
| 1241 |
+
balance = get_ledger().get_balance(player_id)
|
| 1242 |
+
goal.status = GoalStatus.ACTIVE.value
|
| 1243 |
+
goal.started_at = _dt.utcnow()
|
| 1244 |
+
goal.starting_balance_at_goal_start_cents = balance
|
| 1245 |
+
s.commit()
|
| 1246 |
+
return "ok"
|
| 1247 |
+
|
| 1248 |
+
try:
|
| 1249 |
+
result = await asyncio.to_thread(_activate_goal)
|
| 1250 |
+
if result == "not_found":
|
| 1251 |
+
await _send_error(sid, "goal_not_found", "Goal not found or not yours.")
|
| 1252 |
+
return
|
| 1253 |
+
if result == "not_proposed":
|
| 1254 |
+
await _send_error(sid, "goal_not_proposed", "Goal is not in proposed state.")
|
| 1255 |
+
return
|
| 1256 |
+
|
| 1257 |
+
await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "active"}, to=sid, namespace=ROOM_NAMESPACE)
|
| 1258 |
+
logger.info("[room/agent] goal confirmed agent=%s goal=%s", agent_id, goal_id)
|
| 1259 |
+
|
| 1260 |
+
# Launch the autonomous loop.
|
| 1261 |
+
from app.economy.goal_runner import run_goal_loop
|
| 1262 |
+
asyncio.create_task(
|
| 1263 |
+
run_goal_loop(goal_id),
|
| 1264 |
+
name=f"goal-loop-{goal_id}",
|
| 1265 |
+
)
|
| 1266 |
+
except Exception:
|
| 1267 |
+
logger.exception("[room/agent] confirm_goal failed agent=%s goal=%s", agent_id, goal_id)
|
| 1268 |
+
await _send_error(sid, "internal", "Failed to start goal loop.")
|
| 1269 |
+
|
| 1270 |
+
|
| 1271 |
+
@sio.on(C2S_REJECT_GOAL, namespace=ROOM_NAMESPACE)
|
| 1272 |
+
async def _on_reject_goal(sid, data):
|
| 1273 |
+
agent_id, player_id = await _get_agent_session_data(sid)
|
| 1274 |
+
if not agent_id or not player_id:
|
| 1275 |
+
return
|
| 1276 |
+
|
| 1277 |
+
goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
|
| 1278 |
+
if not goal_id:
|
| 1279 |
+
return
|
| 1280 |
+
|
| 1281 |
+
def _reject():
|
| 1282 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1283 |
+
from app.models.player_agent import PlayerAgent as _PA
|
| 1284 |
+
|
| 1285 |
+
with SessionLocal() as s:
|
| 1286 |
+
goal = s.get(AgentGoal, goal_id)
|
| 1287 |
+
if goal is None or goal.agent_id != agent_id:
|
| 1288 |
+
return
|
| 1289 |
+
if goal.status == GoalStatus.PROPOSED.value:
|
| 1290 |
+
goal.status = GoalStatus.STOPPED.value
|
| 1291 |
+
ag = s.get(_PA, agent_id)
|
| 1292 |
+
if ag and ag.active_goal_id == goal_id:
|
| 1293 |
+
ag.active_goal_id = None
|
| 1294 |
+
s.commit()
|
| 1295 |
+
|
| 1296 |
+
try:
|
| 1297 |
+
await asyncio.to_thread(_reject)
|
| 1298 |
+
await sio.emit(S2C_GOAL_REJECTED, {"goal_id": goal_id}, to=sid, namespace=ROOM_NAMESPACE)
|
| 1299 |
+
logger.info("[room/agent] goal rejected agent=%s goal=%s", agent_id, goal_id)
|
| 1300 |
+
except Exception:
|
| 1301 |
+
logger.exception("[room/agent] reject_goal failed agent=%s", agent_id)
|
| 1302 |
+
|
| 1303 |
+
|
| 1304 |
+
@sio.on(C2S_PAUSE_GOAL, namespace=ROOM_NAMESPACE)
|
| 1305 |
+
async def _on_pause_goal(sid, data):
|
| 1306 |
+
agent_id, player_id = await _get_agent_session_data(sid)
|
| 1307 |
+
if not agent_id or not player_id:
|
| 1308 |
+
return
|
| 1309 |
+
|
| 1310 |
+
goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
|
| 1311 |
+
if not goal_id:
|
| 1312 |
+
return
|
| 1313 |
+
|
| 1314 |
+
def _pause():
|
| 1315 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1316 |
+
|
| 1317 |
+
with SessionLocal() as s:
|
| 1318 |
+
goal = s.get(AgentGoal, goal_id)
|
| 1319 |
+
if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
|
| 1320 |
+
return "not_found"
|
| 1321 |
+
if goal.status != GoalStatus.ACTIVE.value:
|
| 1322 |
+
return "not_active"
|
| 1323 |
+
goal.status = GoalStatus.PAUSED.value
|
| 1324 |
+
s.commit()
|
| 1325 |
+
return "ok"
|
| 1326 |
+
|
| 1327 |
+
try:
|
| 1328 |
+
result = await asyncio.to_thread(_pause)
|
| 1329 |
+
status = "paused" if result == "ok" else result
|
| 1330 |
+
await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": status}, to=sid, namespace=ROOM_NAMESPACE)
|
| 1331 |
+
logger.info("[room/agent] goal paused agent=%s goal=%s result=%s", agent_id, goal_id, result)
|
| 1332 |
+
except Exception:
|
| 1333 |
+
logger.exception("[room/agent] pause_goal failed agent=%s", agent_id)
|
| 1334 |
+
|
| 1335 |
+
|
| 1336 |
+
@sio.on(C2S_RESUME_GOAL, namespace=ROOM_NAMESPACE)
|
| 1337 |
+
async def _on_resume_goal(sid, data):
|
| 1338 |
+
agent_id, player_id = await _get_agent_session_data(sid)
|
| 1339 |
+
if not agent_id or not player_id:
|
| 1340 |
+
return
|
| 1341 |
+
|
| 1342 |
+
goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
|
| 1343 |
+
if not goal_id:
|
| 1344 |
+
return
|
| 1345 |
+
|
| 1346 |
+
def _resume():
|
| 1347 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1348 |
+
|
| 1349 |
+
with SessionLocal() as s:
|
| 1350 |
+
goal = s.get(AgentGoal, goal_id)
|
| 1351 |
+
if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
|
| 1352 |
+
return None
|
| 1353 |
+
if goal.status != GoalStatus.PAUSED.value:
|
| 1354 |
+
return None
|
| 1355 |
+
goal.status = GoalStatus.ACTIVE.value
|
| 1356 |
+
s.commit()
|
| 1357 |
+
return goal_id
|
| 1358 |
+
|
| 1359 |
+
try:
|
| 1360 |
+
resumed_id = await asyncio.to_thread(_resume)
|
| 1361 |
+
if resumed_id:
|
| 1362 |
+
await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "active"}, to=sid, namespace=ROOM_NAMESPACE)
|
| 1363 |
+
logger.info("[room/agent] goal resumed agent=%s goal=%s", agent_id, goal_id)
|
| 1364 |
+
from app.economy.goal_runner import run_goal_loop
|
| 1365 |
+
asyncio.create_task(
|
| 1366 |
+
run_goal_loop(goal_id),
|
| 1367 |
+
name=f"goal-loop-{goal_id}",
|
| 1368 |
+
)
|
| 1369 |
+
except Exception:
|
| 1370 |
+
logger.exception("[room/agent] resume_goal failed agent=%s", agent_id)
|
| 1371 |
+
|
| 1372 |
+
|
| 1373 |
+
@sio.on(C2S_STOP_GOAL, namespace=ROOM_NAMESPACE)
|
| 1374 |
+
async def _on_stop_goal(sid, data):
|
| 1375 |
+
agent_id, player_id = await _get_agent_session_data(sid)
|
| 1376 |
+
if not agent_id or not player_id:
|
| 1377 |
+
return
|
| 1378 |
+
|
| 1379 |
+
goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
|
| 1380 |
+
if not goal_id:
|
| 1381 |
+
return
|
| 1382 |
+
|
| 1383 |
+
def _stop():
|
| 1384 |
+
from datetime import datetime as _dt
|
| 1385 |
+
from app.models.agent_goal import AgentGoal, GoalStatus
|
| 1386 |
+
from app.models.player_agent import PlayerAgent as _PA
|
| 1387 |
+
|
| 1388 |
+
with SessionLocal() as s:
|
| 1389 |
+
goal = s.get(AgentGoal, goal_id)
|
| 1390 |
+
if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
|
| 1391 |
+
return "not_found"
|
| 1392 |
+
goal.status = GoalStatus.STOPPED.value
|
| 1393 |
+
goal.completed_at = _dt.utcnow()
|
| 1394 |
+
ag = s.get(_PA, agent_id)
|
| 1395 |
+
if ag and ag.active_goal_id == goal_id:
|
| 1396 |
+
ag.active_goal_id = None
|
| 1397 |
+
s.commit()
|
| 1398 |
+
return "ok"
|
| 1399 |
+
|
| 1400 |
+
try:
|
| 1401 |
+
result = await asyncio.to_thread(_stop)
|
| 1402 |
+
await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "stopped"}, to=sid, namespace=ROOM_NAMESPACE)
|
| 1403 |
+
logger.info("[room/agent] goal stopped agent=%s goal=%s result=%s", agent_id, goal_id, result)
|
| 1404 |
+
except Exception:
|
| 1405 |
+
logger.exception("[room/agent] stop_goal failed agent=%s", agent_id)
|
app/web/templates/agent_room.html
CHANGED
|
@@ -98,6 +98,31 @@
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
<div id="thinking-indicator" class="hidden text-[12px] text-[var(--mp-ink-faint)] mp-mono mb-2">
|
| 102 |
<span class="mp-livedot"></span> {{ agent.name }} is thinking…
|
| 103 |
</div>
|
|
@@ -133,11 +158,22 @@
|
|
| 133 |
const rateLimitHint = document.getElementById('rate-limit-hint');
|
| 134 |
const loadEarlierWrapper = document.getElementById('load-earlier-wrapper');
|
| 135 |
const loadEarlierBtn = document.getElementById('load-earlier-btn');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
let isThinking = false;
|
| 138 |
let oldestTurnNumber = null;
|
| 139 |
let hasEarlier = false;
|
| 140 |
let isLoadingEarlier = false;
|
|
|
|
|
|
|
| 141 |
|
| 142 |
// --- DOM helpers ----------------------------------------------------------
|
| 143 |
|
|
@@ -156,12 +192,87 @@
|
|
| 156 |
function kindLabel(kind) {
|
| 157 |
const labels = {
|
| 158 |
match_report: '📊 match report',
|
|
|
|
|
|
|
| 159 |
plan: '📋 plan',
|
| 160 |
alert: '⚠ alert',
|
| 161 |
};
|
| 162 |
return labels[kind] || null;
|
| 163 |
}
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
function appendBubble(role, text, { kind = 'message', prepend = false, turnId = null } = {}) {
|
| 166 |
hideEmptyHint();
|
| 167 |
const isMatchReport = kind === 'match_report';
|
|
@@ -171,14 +282,23 @@
|
|
| 171 |
const wrapper = document.createElement('div');
|
| 172 |
wrapper.dataset.turnId = turnId || '';
|
| 173 |
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
wrapper.className = 'mr-auto max-w-[85%]';
|
| 176 |
const label = document.createElement('div');
|
| 177 |
label.className = 'chat-kind-label';
|
| 178 |
label.textContent = kindLabel(kind) || kind;
|
| 179 |
wrapper.appendChild(label);
|
| 180 |
const bubble = document.createElement('div');
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
bubble.textContent = text;
|
| 183 |
wrapper.appendChild(bubble);
|
| 184 |
} else {
|
|
@@ -340,6 +460,28 @@
|
|
| 340 |
setTimeout(() => rateLimitHint.classList.add('hidden'), data.retry_after_ms || 2000);
|
| 341 |
});
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
// --- Send -----------------------------------------------------------------
|
| 344 |
|
| 345 |
function sendMessage() {
|
|
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
|
| 101 |
+
{# Goal status panel — shown when an active/paused goal exists #}
|
| 102 |
+
<div id="goal-status-panel" class="hidden mb-3 p-3 border border-[var(--mp-brass-dim)] bg-[rgba(201,166,107,0.06)] rounded-sm">
|
| 103 |
+
<div class="flex items-center justify-between gap-2">
|
| 104 |
+
<div class="text-[12px] text-[var(--mp-ink-muted)]">
|
| 105 |
+
<span class="mp-mono text-[var(--mp-brass)]">Goal active</span>
|
| 106 |
+
<span id="goal-status-text" class="ml-2"></span>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="flex gap-2">
|
| 109 |
+
<button id="goal-pause-btn" class="text-[11px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)] border border-[var(--mp-hairline)] px-2 py-1 rounded-sm transition hidden">Pause</button>
|
| 110 |
+
<button id="goal-resume-btn" class="text-[11px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)] border border-[var(--mp-hairline)] px-2 py-1 rounded-sm transition hidden">Resume</button>
|
| 111 |
+
<button id="goal-stop-btn" class="text-[11px] mp-mono text-[var(--mp-oxblood)] hover:text-[var(--mp-oxblood-bright)] border border-[var(--mp-oxblood)] px-2 py-1 rounded-sm transition">Stop</button>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{# Goal confirmation panel — shown after agent emits propose_goal #}
|
| 117 |
+
<div id="goal-confirm-panel" class="hidden mb-3 p-3 border border-[var(--mp-brass)] bg-[rgba(201,166,107,0.10)] rounded-sm">
|
| 118 |
+
<div class="mp-eyebrow mb-2 text-[11px]">{{ agent.name }} wants to run a goal</div>
|
| 119 |
+
<div id="goal-plan-summary" class="text-[12px] text-[var(--mp-ink-muted)] mb-3"></div>
|
| 120 |
+
<div class="flex gap-2">
|
| 121 |
+
<button id="goal-confirm-btn" class="mp-btn mp-btn-brass text-[12px]">Confirm</button>
|
| 122 |
+
<button id="goal-reject-btn" class="text-[12px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-oxblood)] transition">Reject</button>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
<div id="thinking-indicator" class="hidden text-[12px] text-[var(--mp-ink-faint)] mp-mono mb-2">
|
| 127 |
<span class="mp-livedot"></span> {{ agent.name }} is thinking…
|
| 128 |
</div>
|
|
|
|
| 158 |
const rateLimitHint = document.getElementById('rate-limit-hint');
|
| 159 |
const loadEarlierWrapper = document.getElementById('load-earlier-wrapper');
|
| 160 |
const loadEarlierBtn = document.getElementById('load-earlier-btn');
|
| 161 |
+
const goalStatusPanel = document.getElementById('goal-status-panel');
|
| 162 |
+
const goalStatusText = document.getElementById('goal-status-text');
|
| 163 |
+
const goalPauseBtn = document.getElementById('goal-pause-btn');
|
| 164 |
+
const goalResumeBtn = document.getElementById('goal-resume-btn');
|
| 165 |
+
const goalStopBtn = document.getElementById('goal-stop-btn');
|
| 166 |
+
const goalConfirmPanel = document.getElementById('goal-confirm-panel');
|
| 167 |
+
const goalPlanSummary = document.getElementById('goal-plan-summary');
|
| 168 |
+
const goalConfirmBtn = document.getElementById('goal-confirm-btn');
|
| 169 |
+
const goalRejectBtn = document.getElementById('goal-reject-btn');
|
| 170 |
|
| 171 |
let isThinking = false;
|
| 172 |
let oldestTurnNumber = null;
|
| 173 |
let hasEarlier = false;
|
| 174 |
let isLoadingEarlier = false;
|
| 175 |
+
let activeGoalId = null;
|
| 176 |
+
let activeGoalStatus = null;
|
| 177 |
|
| 178 |
// --- DOM helpers ----------------------------------------------------------
|
| 179 |
|
|
|
|
| 192 |
function kindLabel(kind) {
|
| 193 |
const labels = {
|
| 194 |
match_report: '📊 match report',
|
| 195 |
+
match_intent: '🎯 upcoming match',
|
| 196 |
+
goal_complete: '🏁 goal complete',
|
| 197 |
plan: '📋 plan',
|
| 198 |
alert: '⚠ alert',
|
| 199 |
};
|
| 200 |
return labels[kind] || null;
|
| 201 |
}
|
| 202 |
|
| 203 |
+
// --- Goal UI helpers -------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
function showGoalConfirmPanel(goalId, plan) {
|
| 206 |
+
activeGoalId = goalId;
|
| 207 |
+
const stakeClayDisplay = (plan.max_stake_per_match_cents / 100).toFixed(0);
|
| 208 |
+
const lossClayDisplay = (plan.max_total_loss_cents / 100).toFixed(0);
|
| 209 |
+
const balanceDisplay = (plan.balance_cents / 100).toFixed(0);
|
| 210 |
+
let summary = `${plan.max_matches} matches vs Kenji — ${stakeClayDisplay} $CLAY starting stake — max loss ${lossClayDisplay} $CLAY`;
|
| 211 |
+
if (plan.target_profit_cents) {
|
| 212 |
+
summary += ` — target +${(plan.target_profit_cents / 100).toFixed(0)} $CLAY`;
|
| 213 |
+
}
|
| 214 |
+
goalPlanSummary.textContent = summary;
|
| 215 |
+
goalConfirmPanel.classList.remove('hidden');
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function hideGoalConfirmPanel() {
|
| 219 |
+
goalConfirmPanel.classList.add('hidden');
|
| 220 |
+
goalPlanSummary.textContent = '';
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function showGoalStatusPanel(goalId, status, matchesPlayed, maxMatches, totalProfitCents) {
|
| 224 |
+
activeGoalId = goalId;
|
| 225 |
+
activeGoalStatus = status;
|
| 226 |
+
const profit = totalProfitCents !== undefined ? `${totalProfitCents > 0 ? '+' : ''}${(totalProfitCents / 100).toFixed(0)} $CLAY` : '';
|
| 227 |
+
const progress = matchesPlayed !== undefined ? `${matchesPlayed}/${maxMatches} matches` : '';
|
| 228 |
+
goalStatusText.textContent = [progress, profit].filter(Boolean).join(' · ');
|
| 229 |
+
goalStatusPanel.classList.remove('hidden');
|
| 230 |
+
goalPauseBtn.classList.toggle('hidden', status !== 'active');
|
| 231 |
+
goalResumeBtn.classList.toggle('hidden', status !== 'paused');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function hideGoalStatusPanel() {
|
| 235 |
+
goalStatusPanel.classList.add('hidden');
|
| 236 |
+
activeGoalId = null;
|
| 237 |
+
activeGoalStatus = null;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// Goal panel button wiring.
|
| 241 |
+
if (goalConfirmBtn) {
|
| 242 |
+
goalConfirmBtn.addEventListener('click', function () {
|
| 243 |
+
if (!activeGoalId) return;
|
| 244 |
+
socket.emit('confirm_goal', { goal_id: activeGoalId });
|
| 245 |
+
hideGoalConfirmPanel();
|
| 246 |
+
});
|
| 247 |
+
}
|
| 248 |
+
if (goalRejectBtn) {
|
| 249 |
+
goalRejectBtn.addEventListener('click', function () {
|
| 250 |
+
if (!activeGoalId) return;
|
| 251 |
+
socket.emit('reject_goal', { goal_id: activeGoalId });
|
| 252 |
+
hideGoalConfirmPanel();
|
| 253 |
+
activeGoalId = null;
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
+
if (goalPauseBtn) {
|
| 257 |
+
goalPauseBtn.addEventListener('click', function () {
|
| 258 |
+
if (!activeGoalId) return;
|
| 259 |
+
socket.emit('pause_goal', { goal_id: activeGoalId });
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
if (goalResumeBtn) {
|
| 263 |
+
goalResumeBtn.addEventListener('click', function () {
|
| 264 |
+
if (!activeGoalId) return;
|
| 265 |
+
socket.emit('resume_goal', { goal_id: activeGoalId });
|
| 266 |
+
});
|
| 267 |
+
}
|
| 268 |
+
if (goalStopBtn) {
|
| 269 |
+
goalStopBtn.addEventListener('click', function () {
|
| 270 |
+
if (!activeGoalId) return;
|
| 271 |
+
if (!confirm('Stop the autonomous goal? This cannot be undone.')) return;
|
| 272 |
+
socket.emit('stop_goal', { goal_id: activeGoalId });
|
| 273 |
+
});
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
function appendBubble(role, text, { kind = 'message', prepend = false, turnId = null } = {}) {
|
| 277 |
hideEmptyHint();
|
| 278 |
const isMatchReport = kind === 'match_report';
|
|
|
|
| 282 |
const wrapper = document.createElement('div');
|
| 283 |
wrapper.dataset.turnId = turnId || '';
|
| 284 |
|
| 285 |
+
const isMatchIntent = kind === 'match_intent';
|
| 286 |
+
const isGoalComplete = kind === 'goal_complete';
|
| 287 |
+
|
| 288 |
+
if (isAgent && (isMatchReport || isOtherSpecial || isMatchIntent || isGoalComplete)) {
|
| 289 |
wrapper.className = 'mr-auto max-w-[85%]';
|
| 290 |
const label = document.createElement('div');
|
| 291 |
label.className = 'chat-kind-label';
|
| 292 |
label.textContent = kindLabel(kind) || kind;
|
| 293 |
wrapper.appendChild(label);
|
| 294 |
const bubble = document.createElement('div');
|
| 295 |
+
if (isGoalComplete) {
|
| 296 |
+
bubble.className = 'chat-bubble-match-report border-l-2 border-[var(--mp-brass)]';
|
| 297 |
+
} else if (isMatchIntent) {
|
| 298 |
+
bubble.className = 'p-3 text-[12px] text-[var(--mp-ink-muted)] border border-[var(--mp-hairline)] rounded-sm bg-[var(--mp-surface-2)]';
|
| 299 |
+
} else {
|
| 300 |
+
bubble.className = 'chat-bubble-match-report';
|
| 301 |
+
}
|
| 302 |
bubble.textContent = text;
|
| 303 |
wrapper.appendChild(bubble);
|
| 304 |
} else {
|
|
|
|
| 460 |
setTimeout(() => rateLimitHint.classList.add('hidden'), data.retry_after_ms || 2000);
|
| 461 |
});
|
| 462 |
|
| 463 |
+
// --- Goal events -----------------------------------------------------------
|
| 464 |
+
|
| 465 |
+
socket.on('goal_proposed', function (data) {
|
| 466 |
+
showGoalConfirmPanel(data.goal_id, data.plan);
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
socket.on('goal_status', function (data) {
|
| 470 |
+
const { goal_id, status, matches_played, max_matches, total_profit_cents } = data;
|
| 471 |
+
if (status === 'active' || status === 'paused') {
|
| 472 |
+
showGoalStatusPanel(goal_id, status, matches_played, max_matches, total_profit_cents);
|
| 473 |
+
} else {
|
| 474 |
+
// completed, stopped, failed, etc.
|
| 475 |
+
hideGoalStatusPanel();
|
| 476 |
+
appendBubble('agent', `[ Goal ${status}. ]`, { kind: 'goal_complete' });
|
| 477 |
+
}
|
| 478 |
+
});
|
| 479 |
+
|
| 480 |
+
socket.on('goal_rejected', function (data) {
|
| 481 |
+
hideGoalConfirmPanel();
|
| 482 |
+
activeGoalId = null;
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
// --- Send -----------------------------------------------------------------
|
| 486 |
|
| 487 |
function sendMessage() {
|