Spaces:
Sleeping
Sleeping
feat(endgame): elimination-based game over + overlay; fix LLM chat_format and i18n draw
Browse files- __pycache__/ai_analysis.cpython-312.pyc +0 -0
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/localization.cpython-312.pyc +0 -0
- ai_analysis.py +23 -11
- app.py +46 -0
- localization.py +6 -0
- static/game.js +28 -0
- static/index.html +10 -0
- static/styles.css +32 -0
__pycache__/ai_analysis.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/ai_analysis.cpython-312.pyc and b/__pycache__/ai_analysis.cpython-312.pyc differ
|
|
|
__pycache__/app.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-312.pyc and b/__pycache__/app.cpython-312.pyc differ
|
|
|
__pycache__/localization.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/localization.cpython-312.pyc and b/__pycache__/localization.cpython-312.pyc differ
|
|
|
ai_analysis.py
CHANGED
|
@@ -42,16 +42,28 @@ def _llama_worker(result_queue, model_path, prompt, messages, max_tokens, temper
|
|
| 42 |
result_queue.put({'status': 'error', 'message': f"llama-cpp import failed: {exc}"})
|
| 43 |
return
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return
|
| 56 |
|
| 57 |
try:
|
|
@@ -120,7 +132,7 @@ def _llama_worker(result_queue, model_path, prompt, messages, max_tokens, temper
|
|
| 120 |
prompt or '',
|
| 121 |
max_tokens=max_tokens,
|
| 122 |
temperature=temperature,
|
| 123 |
-
stop=["
|
| 124 |
)
|
| 125 |
except Exception:
|
| 126 |
raw_resp = None
|
|
|
|
| 42 |
result_queue.put({'status': 'error', 'message': f"llama-cpp import failed: {exc}"})
|
| 43 |
return
|
| 44 |
|
| 45 |
+
# Try loading the model with best-suited chat template for Qwen2.5
|
| 46 |
+
n_threads = max(1, min(4, os.cpu_count() or 2))
|
| 47 |
+
last_exc = None
|
| 48 |
+
llama = None
|
| 49 |
+
for chat_fmt in ('qwen2', 'qwen', None):
|
| 50 |
+
try:
|
| 51 |
+
kwargs: Dict[str, Any] = dict(
|
| 52 |
+
model_path=model_path,
|
| 53 |
+
n_ctx=4096,
|
| 54 |
+
n_threads=n_threads,
|
| 55 |
+
verbose=False,
|
| 56 |
+
)
|
| 57 |
+
if chat_fmt is not None:
|
| 58 |
+
kwargs['chat_format'] = chat_fmt # type: ignore[index]
|
| 59 |
+
llama = Llama(**kwargs) # type: ignore[arg-type]
|
| 60 |
+
break
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
last_exc = exc
|
| 63 |
+
llama = None
|
| 64 |
+
continue
|
| 65 |
+
if llama is None:
|
| 66 |
+
result_queue.put({'status': 'error', 'message': f"Failed to load model: {last_exc}"})
|
| 67 |
return
|
| 68 |
|
| 69 |
try:
|
|
|
|
| 132 |
prompt or '',
|
| 133 |
max_tokens=max_tokens,
|
| 134 |
temperature=temperature,
|
| 135 |
+
stop=["</s>", "<|endoftext|>"]
|
| 136 |
)
|
| 137 |
except Exception:
|
| 138 |
raw_resp = None
|
app.py
CHANGED
|
@@ -761,6 +761,52 @@ class ConnectionManager:
|
|
| 761 |
free_pos = self.find_free_position_nearby(spawn_pos, new_unit.id)
|
| 762 |
new_unit.position = free_pos
|
| 763 |
building.production_progress = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
|
| 765 |
def find_nearest_enemy(self, unit: Unit) -> Optional[Unit]:
|
| 766 |
"""RED ALERT: Find nearest enemy unit"""
|
|
|
|
| 761 |
free_pos = self.find_free_position_nearby(spawn_pos, new_unit.id)
|
| 762 |
new_unit.position = free_pos
|
| 763 |
building.production_progress = 0
|
| 764 |
+
|
| 765 |
+
# Endgame checks: elimination-first (after all destructions this tick)
|
| 766 |
+
if not self.game_state.game_over:
|
| 767 |
+
p_units = sum(1 for u in self.game_state.units.values() if u.player_id == 0)
|
| 768 |
+
e_units = sum(1 for u in self.game_state.units.values() if u.player_id == 1)
|
| 769 |
+
p_buildings = sum(1 for b in self.game_state.buildings.values() if b.player_id == 0)
|
| 770 |
+
e_buildings = sum(1 for b in self.game_state.buildings.values() if b.player_id == 1)
|
| 771 |
+
|
| 772 |
+
player_language = self.game_state.players[0].language if 0 in self.game_state.players else "en"
|
| 773 |
+
|
| 774 |
+
if p_units == 0 and p_buildings == 0 and (e_units > 0 or e_buildings > 0):
|
| 775 |
+
# Player eliminated
|
| 776 |
+
self.game_state.game_over = True
|
| 777 |
+
self.game_state.winner = "enemy"
|
| 778 |
+
winner_name = LOCALIZATION.translate(player_language, "game.winner.enemy")
|
| 779 |
+
message = LOCALIZATION.translate(player_language, "game.win.banner", winner=winner_name)
|
| 780 |
+
asyncio.create_task(self.broadcast({
|
| 781 |
+
"type": "game_over",
|
| 782 |
+
"winner": "enemy",
|
| 783 |
+
"message": message
|
| 784 |
+
}))
|
| 785 |
+
elif e_units == 0 and e_buildings == 0 and (p_units > 0 or p_buildings > 0):
|
| 786 |
+
# Enemy eliminated
|
| 787 |
+
self.game_state.game_over = True
|
| 788 |
+
self.game_state.winner = "player"
|
| 789 |
+
winner_name = LOCALIZATION.translate(player_language, "game.winner.player")
|
| 790 |
+
message = LOCALIZATION.translate(player_language, "game.win.banner", winner=winner_name)
|
| 791 |
+
asyncio.create_task(self.broadcast({
|
| 792 |
+
"type": "game_over",
|
| 793 |
+
"winner": "player",
|
| 794 |
+
"message": message
|
| 795 |
+
}))
|
| 796 |
+
elif p_units == 0 and p_buildings == 0 and e_units == 0 and e_buildings == 0:
|
| 797 |
+
# Total annihilation -> draw
|
| 798 |
+
self.game_state.game_over = True
|
| 799 |
+
self.game_state.winner = "draw"
|
| 800 |
+
# Localized draw banner if available
|
| 801 |
+
try:
|
| 802 |
+
draw_msg = LOCALIZATION.translate(player_language, "game.draw.banner")
|
| 803 |
+
except Exception:
|
| 804 |
+
draw_msg = "Draw!"
|
| 805 |
+
asyncio.create_task(self.broadcast({
|
| 806 |
+
"type": "game_over",
|
| 807 |
+
"winner": "draw",
|
| 808 |
+
"message": draw_msg
|
| 809 |
+
}))
|
| 810 |
|
| 811 |
def find_nearest_enemy(self, unit: Unit) -> Optional[Unit]:
|
| 812 |
"""RED ALERT: Find nearest enemy unit"""
|
localization.py
CHANGED
|
@@ -83,6 +83,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 83 |
"menu.actions.stop.tooltip": "Stop selected units",
|
| 84 |
"menu.actions.attack_move": "Attack Move",
|
| 85 |
"menu.actions.attack_move.tooltip": "Attack enemies while moving (hotkey: A)",
|
|
|
|
| 86 |
"menu.control_groups.title": "🎮 Control Groups",
|
| 87 |
"menu.stats.title": "📈 Game Stats",
|
| 88 |
"menu.stats.player_units": "Player Units:",
|
|
@@ -132,6 +133,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 132 |
"hud.model.download.retry": "Retrying download…",
|
| 133 |
"hud.model.download.done": "Model ready",
|
| 134 |
"hud.model.download.error": "Model download failed",
|
|
|
|
| 135 |
},
|
| 136 |
"fr": {
|
| 137 |
"game.window.title": "RTS Minimaliste",
|
|
@@ -201,6 +203,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 201 |
"menu.actions.stop.tooltip": "Arrêter les unités sélectionnées",
|
| 202 |
"menu.actions.attack_move": "Attaque en mouvement",
|
| 203 |
"menu.actions.attack_move.tooltip": "Attaquer les ennemis en se déplaçant (raccourci : A)",
|
|
|
|
| 204 |
"menu.control_groups.title": "🎮 Groupes de contrôle",
|
| 205 |
"menu.stats.title": "📈 Statistiques",
|
| 206 |
"menu.stats.player_units": "Unités joueur :",
|
|
@@ -241,6 +244,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 241 |
"menu.sound.disable": "Désactiver le son",
|
| 242 |
"notification.sound.enabled": "Son activé",
|
| 243 |
"notification.sound.disabled": "Son désactivé",
|
|
|
|
| 244 |
"menu.camera.zoom_in": "Zoom avant",
|
| 245 |
"menu.camera.zoom_out": "Zoom arrière",
|
| 246 |
"menu.camera.reset": "Réinitialiser la vue",
|
|
@@ -319,6 +323,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 319 |
"menu.actions.stop.tooltip": "停止選取的單位",
|
| 320 |
"menu.actions.attack_move": "攻擊移動",
|
| 321 |
"menu.actions.attack_move.tooltip": "移動時攻擊敵人(快捷鍵:A)",
|
|
|
|
| 322 |
"menu.control_groups.title": "🎮 控制組",
|
| 323 |
"menu.stats.title": "📈 遊戲統計",
|
| 324 |
"menu.stats.player_units": "玩家單位:",
|
|
@@ -368,6 +373,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
| 368 |
"hud.model.download.retry": "重試下載…",
|
| 369 |
"hud.model.download.done": "模型已就緒",
|
| 370 |
"hud.model.download.error": "模型下載失敗",
|
|
|
|
| 371 |
},
|
| 372 |
}
|
| 373 |
|
|
|
|
| 83 |
"menu.actions.stop.tooltip": "Stop selected units",
|
| 84 |
"menu.actions.attack_move": "Attack Move",
|
| 85 |
"menu.actions.attack_move.tooltip": "Attack enemies while moving (hotkey: A)",
|
| 86 |
+
"menu.actions.restart": "Restart",
|
| 87 |
"menu.control_groups.title": "🎮 Control Groups",
|
| 88 |
"menu.stats.title": "📈 Game Stats",
|
| 89 |
"menu.stats.player_units": "Player Units:",
|
|
|
|
| 133 |
"hud.model.download.retry": "Retrying download…",
|
| 134 |
"hud.model.download.done": "Model ready",
|
| 135 |
"hud.model.download.error": "Model download failed",
|
| 136 |
+
"game.draw.banner": "Draw!",
|
| 137 |
},
|
| 138 |
"fr": {
|
| 139 |
"game.window.title": "RTS Minimaliste",
|
|
|
|
| 203 |
"menu.actions.stop.tooltip": "Arrêter les unités sélectionnées",
|
| 204 |
"menu.actions.attack_move": "Attaque en mouvement",
|
| 205 |
"menu.actions.attack_move.tooltip": "Attaquer les ennemis en se déplaçant (raccourci : A)",
|
| 206 |
+
"menu.actions.restart": "Recommencer",
|
| 207 |
"menu.control_groups.title": "🎮 Groupes de contrôle",
|
| 208 |
"menu.stats.title": "📈 Statistiques",
|
| 209 |
"menu.stats.player_units": "Unités joueur :",
|
|
|
|
| 244 |
"menu.sound.disable": "Désactiver le son",
|
| 245 |
"notification.sound.enabled": "Son activé",
|
| 246 |
"notification.sound.disabled": "Son désactivé",
|
| 247 |
+
"game.draw.banner": "Match nul !",
|
| 248 |
"menu.camera.zoom_in": "Zoom avant",
|
| 249 |
"menu.camera.zoom_out": "Zoom arrière",
|
| 250 |
"menu.camera.reset": "Réinitialiser la vue",
|
|
|
|
| 323 |
"menu.actions.stop.tooltip": "停止選取的單位",
|
| 324 |
"menu.actions.attack_move": "攻擊移動",
|
| 325 |
"menu.actions.attack_move.tooltip": "移動時攻擊敵人(快捷鍵:A)",
|
| 326 |
+
"menu.actions.restart": "重新開始",
|
| 327 |
"menu.control_groups.title": "🎮 控制組",
|
| 328 |
"menu.stats.title": "📈 遊戲統計",
|
| 329 |
"menu.stats.player_units": "玩家單位:",
|
|
|
|
| 373 |
"hud.model.download.retry": "重試下載…",
|
| 374 |
"hud.model.download.done": "模型已就緒",
|
| 375 |
"hud.model.download.error": "模型下載失敗",
|
| 376 |
+
"game.draw.banner": "平手!",
|
| 377 |
},
|
| 378 |
}
|
| 379 |
|
static/game.js
CHANGED
|
@@ -204,10 +204,38 @@ class GameClient {
|
|
| 204 |
case 'notification':
|
| 205 |
this.showNotification(data.message, data.level || 'info');
|
| 206 |
break;
|
|
|
|
|
|
|
|
|
|
| 207 |
default:
|
| 208 |
console.log('Unknown message type:', data.type);
|
| 209 |
}
|
| 210 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
sendCommand(command) {
|
| 213 |
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
|
|
| 204 |
case 'notification':
|
| 205 |
this.showNotification(data.message, data.level || 'info');
|
| 206 |
break;
|
| 207 |
+
case 'game_over':
|
| 208 |
+
this.onGameOver(data);
|
| 209 |
+
break;
|
| 210 |
default:
|
| 211 |
console.log('Unknown message type:', data.type);
|
| 212 |
}
|
| 213 |
}
|
| 214 |
+
|
| 215 |
+
onGameOver(payload) {
|
| 216 |
+
// Show overlay with localized message
|
| 217 |
+
const overlay = document.getElementById('game-over-overlay');
|
| 218 |
+
const msgEl = document.getElementById('game-over-message');
|
| 219 |
+
const btn = document.getElementById('game-over-restart');
|
| 220 |
+
if (overlay && msgEl && btn) {
|
| 221 |
+
// Prefer server-provided message; fallback by winner
|
| 222 |
+
let message = payload.message || '';
|
| 223 |
+
if (!message) {
|
| 224 |
+
const key = payload.winner === 'player' ? 'game.winner.player' : payload.winner === 'enemy' ? 'game.winner.enemy' : 'game.draw.banner';
|
| 225 |
+
if (payload.winner === 'draw') {
|
| 226 |
+
message = this.translate('game.draw.banner');
|
| 227 |
+
} else {
|
| 228 |
+
const winnerName = this.translate(key);
|
| 229 |
+
message = this.translate('game.win.banner', { winner: winnerName });
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
msgEl.textContent = message;
|
| 233 |
+
// Localize button
|
| 234 |
+
btn.textContent = this.translate('menu.actions.restart') || 'Restart';
|
| 235 |
+
btn.onclick = () => window.location.reload();
|
| 236 |
+
overlay.classList.remove('hidden');
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
|
| 240 |
sendCommand(command) {
|
| 241 |
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
static/index.html
CHANGED
|
@@ -235,6 +235,16 @@
|
|
| 235 |
<p>Connecting to server...</p>
|
| 236 |
</div>
|
| 237 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</div>
|
| 239 |
|
| 240 |
<script src="/static/sounds.js"></script>
|
|
|
|
| 235 |
<p>Connecting to server...</p>
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
+
|
| 239 |
+
<!-- Game Over Overlay -->
|
| 240 |
+
<div id="game-over-overlay" class="hidden">
|
| 241 |
+
<div class="game-over-content">
|
| 242 |
+
<h2 id="game-over-message">Game Over</h2>
|
| 243 |
+
<div class="game-over-actions">
|
| 244 |
+
<button id="game-over-restart">Restart</button>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
</div>
|
| 249 |
|
| 250 |
<script src="/static/sounds.js"></script>
|
static/styles.css
CHANGED
|
@@ -6,6 +6,38 @@
|
|
| 6 |
margin: 6px 0 8px;
|
| 7 |
font-size: 12px;
|
| 8 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
#production-source .label { opacity: 0.8; }
|
| 10 |
#production-source-name { font-weight: 700; }
|
| 11 |
#production-source-clear {
|
|
|
|
| 6 |
margin: 6px 0 8px;
|
| 7 |
font-size: 12px;
|
| 8 |
}
|
| 9 |
+
|
| 10 |
+
/* Game Over Overlay */
|
| 11 |
+
#game-over-overlay {
|
| 12 |
+
position: fixed;
|
| 13 |
+
inset: 0;
|
| 14 |
+
background: rgba(0,0,0,0.75);
|
| 15 |
+
display: flex;
|
| 16 |
+
align-items: center;
|
| 17 |
+
justify-content: center;
|
| 18 |
+
z-index: 9999;
|
| 19 |
+
}
|
| 20 |
+
#game-over-overlay.hidden { display: none; }
|
| 21 |
+
.game-over-content {
|
| 22 |
+
background: #111;
|
| 23 |
+
color: #fff;
|
| 24 |
+
padding: 24px 28px;
|
| 25 |
+
border-radius: 8px;
|
| 26 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
| 27 |
+
min-width: 320px;
|
| 28 |
+
text-align: center;
|
| 29 |
+
}
|
| 30 |
+
.game-over-content h2 { margin: 0 0 12px; font-size: 20px; }
|
| 31 |
+
.game-over-actions { display: flex; gap: 12px; justify-content: center; }
|
| 32 |
+
.game-over-actions button {
|
| 33 |
+
background: #2d7ef7;
|
| 34 |
+
color: #fff;
|
| 35 |
+
border: none;
|
| 36 |
+
border-radius: 6px;
|
| 37 |
+
padding: 8px 12px;
|
| 38 |
+
cursor: pointer;
|
| 39 |
+
}
|
| 40 |
+
.game-over-actions button:hover { background: #226ad1; }
|
| 41 |
#production-source .label { opacity: 0.8; }
|
| 42 |
#production-source-name { font-weight: 700; }
|
| 43 |
#production-source-clear {
|