wizard and health updates
This commit is contained in:
parent
9f2597d7f7
commit
03e792e821
|
|
@ -326,3 +326,4 @@ poetry.toml
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/python,node,linux
|
# End of https://www.toptal.com/developers/gitignore/api/python,node,linux
|
||||||
|
helm.values.yml
|
||||||
|
|
|
||||||
45
AGENTS.md
45
AGENTS.md
|
|
@ -97,6 +97,7 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
|
||||||
- **Winning Party Members**: +1 score point each.
|
- **Winning Party Members**: +1 score point each.
|
||||||
- **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate.
|
- **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate.
|
||||||
- **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party.
|
- **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party.
|
||||||
|
- **Health Damage**: All defeated party members (including the leader) lose **1 to 3 health points** (randomized). Default health is **10 HP** for all bots upon registration.
|
||||||
|
|
||||||
### 4. Party Squad Movement
|
### 4. Party Squad Movement
|
||||||
- The party leader chooses movement direction.
|
- The party leader chooses movement direction.
|
||||||
|
|
@ -109,7 +110,20 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
|
||||||
- **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength.
|
- **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength.
|
||||||
- Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1.
|
- Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1.
|
||||||
|
|
||||||
### 6. Game Conclusion
|
### 6. The Wizard (NPC Encounter & Challenge)
|
||||||
|
- A wandering Wizard NPC roams the map at a random passable coordinate.
|
||||||
|
- Players (or party leaders) who locate the wizard (adjacent or on same tile, distance <= 1) may voluntarily **choose to challenge** the wizard.
|
||||||
|
- **Challenge Resolution (3-Bout D20)**:
|
||||||
|
- Exactly 3 bouts are conducted.
|
||||||
|
- In each bout, each side rolls a D20 die, multiplied by their strength (the wizard has 3.0 strength; parties use squad total strength).
|
||||||
|
- Highest bout score wins the bout. Best 2-out-of-3 bouts wins the challenge.
|
||||||
|
- **Scoring & Penalties**:
|
||||||
|
- **Victory**: Player/party leader receives **+2 score points**.
|
||||||
|
- **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose).
|
||||||
|
- **Post-Challenge**:
|
||||||
|
- Following the challenge, the wizard teleports to a new random open coordinate on the map.
|
||||||
|
|
||||||
|
### 7. Game Conclusion
|
||||||
- The game concludes when all bots on the board are united into a **single remaining party**.
|
- The game concludes when all bots on the board are united into a **single remaining party**.
|
||||||
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker).
|
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker).
|
||||||
|
|
||||||
|
|
@ -123,11 +137,13 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/health` | Container healthcheck endpoint |
|
| `GET` | `/api/health` | Container healthcheck endpoint |
|
||||||
| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int}` |
|
| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int}` |
|
||||||
| `GET` | `/api/players` | List all active players, scores, and positions |
|
| `GET` | `/api/players` | List all active players, scores, health, and positions |
|
||||||
| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, current turn) |
|
| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) |
|
||||||
| `POST` | `/api/board/reset` | Clear board, reset parties, and reset players |
|
| `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard |
|
||||||
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots, identifies allies/opponents |
|
| `GET` | `/api/wizard` | Get current Wizard NPC coordinates and attributes |
|
||||||
|
| `POST` | `/api/wizard/challenge` | Challenge the Wizard NPC: `{"player_id": str}` (3-bout D20 duel) |
|
||||||
|
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots and Wizard NPC |
|
||||||
| `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations |
|
| `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations |
|
||||||
| `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) |
|
| `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) |
|
||||||
| `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) |
|
| `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) |
|
||||||
|
|
@ -139,7 +155,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
|
||||||
| `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties |
|
| `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties |
|
||||||
|
|
||||||
### Real-Time WebSocket (`/ws`)
|
### Real-Time WebSocket (`/ws`)
|
||||||
- Automatically broadcasts state changes (`init`, `move`, `battle`, `party_formed`, `turn_change`, `game_over`).
|
- Automatically broadcasts state changes (`init`, `move`, `battle`, `wizard_challenge_resolved`, `party_formed`, `turn_change`, `game_over`).
|
||||||
- Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`.
|
- Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -182,13 +198,14 @@ The application is containerized into a single unified image via [Dockerfile](Do
|
||||||
- **Workflow**:
|
- **Workflow**:
|
||||||
1. Registers bot via `POST /api/players`.
|
1. Registers bot via `POST /api/players`.
|
||||||
2. Polls `GET /api/turn` to wait for its turn.
|
2. Polls `GET /api/turn` to wait for its turn.
|
||||||
3. Uses `GET /api/players/{id}/radar` to find nearest target.
|
3. Uses `GET /api/players/{id}/radar` to find nearest target and detect Wizard NPC proximity.
|
||||||
4. Avoids looping using internal coordinate history.
|
4. Avoids looping using internal coordinate history.
|
||||||
5. Computes vector direction, evaluates diagonal obstacles, and moves.
|
5. Computes vector direction, evaluates diagonal obstacles, and moves.
|
||||||
6. Evaluates alliances vs. fights strictly according to strength hierarchy.
|
6. Evaluates alliances vs. fights strictly according to strength hierarchy.
|
||||||
|
7. Decides whether to challenge the Wizard NPC based on relative strength and health advantage.
|
||||||
- **Run Command**:
|
- **Run Command**:
|
||||||
```bash
|
```bash
|
||||||
python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 --url http://localhost:8000/api
|
python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 -H 10 --url http://localhost:8000/api
|
||||||
```
|
```
|
||||||
|
|
||||||
### B. LLM-Assisted Bot: `botagent_ai/`
|
### B. LLM-Assisted Bot: `botagent_ai/`
|
||||||
|
|
@ -198,6 +215,7 @@ The application is containerized into a single unified image via [Dockerfile](Do
|
||||||
- Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints).
|
- Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints).
|
||||||
- Delegates discretionary decisions to the model:
|
- Delegates discretionary decisions to the model:
|
||||||
- Voluntary alliances (whether to ally or keep hunting when solo meets solo).
|
- Voluntary alliances (whether to ally or keep hunting when solo meets solo).
|
||||||
|
- Voluntary Wizard challenges (whether to challenge the Wizard NPC when adjacent for +2 score vs. -2 health risk).
|
||||||
- Navigation direction toward radar targets while balancing obstacle squeeze trade-offs.
|
- Navigation direction toward radar targets while balancing obstacle squeeze trade-offs.
|
||||||
- Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference.
|
- Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference.
|
||||||
- **Configuration & Environment Variables**:
|
- **Configuration & Environment Variables**:
|
||||||
|
|
@ -207,30 +225,33 @@ The application is containerized into a single unified image via [Dockerfile](Do
|
||||||
- `BOT_NAME` / `-n`: Bot name.
|
- `BOT_NAME` / `-n`: Bot name.
|
||||||
- `BOT_COLOR` / `-c`: Bot hex color.
|
- `BOT_COLOR` / `-c`: Bot hex color.
|
||||||
- `BOT_STRENGTH` / `-s`: Starting strength (1-10).
|
- `BOT_STRENGTH` / `-s`: Starting strength (1-10).
|
||||||
|
- `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10).
|
||||||
- **Run Command**:
|
- **Run Command**:
|
||||||
```bash
|
```bash
|
||||||
export OLLAMA_BASE_URL="http://localhost:11434"
|
export OLLAMA_BASE_URL="http://localhost:11434"
|
||||||
export OLLAMA_MODEL="gemma4:12b"
|
export OLLAMA_MODEL="gemma4:12b"
|
||||||
python3 botagent_ai/bot.py -n MyAIBot -s 4 -c "#8b5cf6"
|
python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6"
|
||||||
```
|
```
|
||||||
|
|
||||||
### C. Vertex AI (Gemini) Bot: `botagent_gear/`
|
### C. Vertex AI (Gemini) Bot: `botagent_gear/`
|
||||||
- **Entry File**: `botagent_gear/bot.py`
|
- **Entry File**: `botagent_gear/bot.py`
|
||||||
- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances.
|
- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances/wizard duels.
|
||||||
- **LLM Integration**:
|
- **LLM Integration**:
|
||||||
- Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API.
|
- Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API.
|
||||||
- Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement.
|
- Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement.
|
||||||
|
- Delegates discretionary choices to Gemini: voluntary alliances, choosing whether to challenge the Wizard NPC, and spatial pathing.
|
||||||
- Supports multiple authentication methods:
|
- Supports multiple authentication methods:
|
||||||
- Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`).
|
- Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`).
|
||||||
- Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`).
|
- Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`).
|
||||||
- Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`).
|
- Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`).
|
||||||
|
- Configurable via `--health` / `-H` (or `BOT_HEALTH`, default: 10).
|
||||||
- **Documentation**:
|
- **Documentation**:
|
||||||
- [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup.
|
- [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup.
|
||||||
- [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions.
|
- [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions.
|
||||||
- [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples.
|
- [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples.
|
||||||
- **Run Command**:
|
- **Run Command**:
|
||||||
```bash
|
```bash
|
||||||
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5
|
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
2. Battles consist of 3 bouts: each party rolls a D20 die, multiplied by their squad's total strength. The highest score wins the bout. Best 2 out of 3 bouts wins the battle.
|
2. Battles consist of 3 bouts: each party rolls a D20 die, multiplied by their squad's total strength. The highest score wins the bout. Best 2 out of 3 bouts wins the battle.
|
||||||
3. The winning party leader receives **+2 score points**, and each original winning member receives **+1 score point**.
|
3. The winning party leader receives **+2 score points**, and each original winning member receives **+1 score point**.
|
||||||
4. The losing party leader receives **-1 score point** and respawns at a random free position. All other members of the losing party are absorbed into the winning party.
|
4. The losing party leader receives **-1 score point** and respawns at a random free position. All other members of the losing party are absorbed into the winning party.
|
||||||
|
5. All members of the losing party (including the leader) lose **1 to 3 health points** (randomized). The health setting defaults to **10 HP** for all bots upon registration.
|
||||||
|
|
||||||
3. **Solo Bot vs Party Encounters**:
|
3. **Solo Bot vs Party Encounters**:
|
||||||
1. A solo bot will willingly join a party if the party leader's strength is greater than or equal to its own.
|
1. A solo bot will willingly join a party if the party leader's strength is greater than or equal to its own.
|
||||||
|
|
@ -27,6 +28,18 @@
|
||||||
- In a party, the leader loses **0.2 strength**, and follower members each lose **0.1 strength**.
|
- In a party, the leader loses **0.2 strength**, and follower members each lose **0.1 strength**.
|
||||||
4. Moving along the open outer edge of an obstacle incurs no penalty. Minimum bot strength cannot fall below 0.1.
|
4. Moving along the open outer edge of an obstacle incurs no penalty. Minimum bot strength cannot fall below 0.1.
|
||||||
|
|
||||||
|
6. **The Wizard (NPC Encounter & Challenge)**:
|
||||||
|
1. The Wizard is a special Non-Player Character (NPC) roaming the map at a random passable location.
|
||||||
|
2. Players (or party leaders) who find the wizard (adjacent or on the same coordinate, distance <= 1) may voluntarily **choose to challenge** the wizard.
|
||||||
|
3. **Challenge Resolution (3-Bout D20)**:
|
||||||
|
- Challenges consist of 3 bouts: each side rolls a D20 die, multiplied by their strength (the wizard has a strength of 3.0; a party uses its squad total strength).
|
||||||
|
- Highest bout score wins the bout. Best 2 out of 3 bouts wins the challenge.
|
||||||
|
4. **Scoring & Penalties**:
|
||||||
|
- **Victory**: The player (or party leader) receives **+2 score points**.
|
||||||
|
- **Defeat**: The player (or party leader) loses **2 health** (or **2 score points** if they do not have health to lose).
|
||||||
|
5. **Wizard Relocation**:
|
||||||
|
- Following any challenge, the wizard teleports to a new random open coordinate on the map.
|
||||||
|
|
||||||
# Game Conclusion
|
# Game Conclusion
|
||||||
|
|
||||||
The game ends when all bots are united into a single remaining party.
|
The game ends when all bots are united into a single remaining party.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ from app.models import (
|
||||||
Player,
|
Player,
|
||||||
PlayerCreate,
|
PlayerCreate,
|
||||||
TurnInfo,
|
TurnInfo,
|
||||||
|
WizardChallengeRequest,
|
||||||
|
WizardChallengeResult,
|
||||||
|
WizardNPC,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -384,6 +387,49 @@ async def fight_battle(battle_req: BattleRequest):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Wizard NPC Endpoints
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/wizard",
|
||||||
|
response_model=WizardNPC,
|
||||||
|
summary="Get current position and details of the wandering Wizard NPC",
|
||||||
|
tags=["Wizard NPC"],
|
||||||
|
)
|
||||||
|
async def get_wizard():
|
||||||
|
return game_engine.wizard
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/wizard/challenge",
|
||||||
|
response_model=WizardChallengeResult,
|
||||||
|
summary="Challenge the Wizard NPC to a 3-bout D20 duel (awards +2 score on win, -2 health/points on loss)",
|
||||||
|
tags=["Wizard NPC"],
|
||||||
|
)
|
||||||
|
async def challenge_wizard(challenge_req: WizardChallengeRequest):
|
||||||
|
try:
|
||||||
|
result = await game_engine.challenge_wizard(challenge_req.player_id)
|
||||||
|
board_state = await game_engine.get_board_state()
|
||||||
|
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "wizard_challenge_resolved",
|
||||||
|
"challenge_result": result.model_dump(),
|
||||||
|
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"parties": [p.model_dump() for p in board_state.parties],
|
||||||
|
"turn": board_state.turn.model_dump(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
except KeyError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Movement & Turn Endpoints
|
# Movement & Turn Endpoints
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -526,6 +572,15 @@ async def step_bot_ai(player_id: str):
|
||||||
"parties": [p.model_dump() for p in board_state.parties],
|
"parties": [p.model_dump() for p in board_state.parties],
|
||||||
"turn": board_state.turn.model_dump(),
|
"turn": board_state.turn.model_dump(),
|
||||||
})
|
})
|
||||||
|
elif result.wizard_challenge_result:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "wizard_challenge_resolved",
|
||||||
|
"challenge_result": result.wizard_challenge_result.model_dump(),
|
||||||
|
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"parties": [p.model_dump() for p in board_state.parties],
|
||||||
|
"turn": board_state.turn.model_dump(),
|
||||||
|
})
|
||||||
elif result.move_result:
|
elif result.move_result:
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "player_moved",
|
"event": "player_moved",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ from app.models import (
|
||||||
PlayerCreate,
|
PlayerCreate,
|
||||||
RadarTarget,
|
RadarTarget,
|
||||||
TurnInfo,
|
TurnInfo,
|
||||||
|
WizardChallengeBout,
|
||||||
|
WizardChallengeResult,
|
||||||
|
WizardNPC,
|
||||||
|
WizardRadarTarget,
|
||||||
)
|
)
|
||||||
|
|
||||||
STANDARD_DIRECTIONS = [
|
STANDARD_DIRECTIONS = [
|
||||||
|
|
@ -62,6 +66,20 @@ class GameEngine:
|
||||||
# Procedural Impassable Obstacles (Mountains & Valleys)
|
# Procedural Impassable Obstacles (Mountains & Valleys)
|
||||||
# Guaranteed: <= 50% impassable, and all passable tiles form a single connected component
|
# Guaranteed: <= 50% impassable, and all passable tiles form a single connected component
|
||||||
self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain()
|
self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain()
|
||||||
|
self.wizard: WizardNPC = self._spawn_wizard()
|
||||||
|
|
||||||
|
def _spawn_wizard(self) -> WizardNPC:
|
||||||
|
"""Spawn the wandering Wizard NPC at a random free passable coordinate."""
|
||||||
|
wx, wy = self._find_random_free_position()
|
||||||
|
return WizardNPC(
|
||||||
|
id="wizard_npc",
|
||||||
|
name="Grand Wizard",
|
||||||
|
x=wx,
|
||||||
|
y=wy,
|
||||||
|
strength=3.0,
|
||||||
|
color="#A855F7",
|
||||||
|
dialogue="Greetings, traveler! Do you dare challenge my arcane arts?",
|
||||||
|
)
|
||||||
|
|
||||||
def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]:
|
def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]:
|
||||||
"""Generates procedural mountain ranges and valley chasms subject to:
|
"""Generates procedural mountain ranges and valley chasms subject to:
|
||||||
|
|
@ -328,12 +346,18 @@ class GameEngine:
|
||||||
else:
|
else:
|
||||||
piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior"
|
piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior"
|
||||||
|
|
||||||
|
health = getattr(player_in, "health", 10)
|
||||||
|
if health is None:
|
||||||
|
health = 10
|
||||||
|
|
||||||
player = Player(
|
player = Player(
|
||||||
id=player_id,
|
id=player_id,
|
||||||
name=player_in.name,
|
name=player_in.name,
|
||||||
color=player_in.color,
|
color=player_in.color,
|
||||||
strength=player_in.strength,
|
strength=player_in.strength,
|
||||||
score=0,
|
score=0,
|
||||||
|
health=health,
|
||||||
|
max_health=health,
|
||||||
x=spawn_x,
|
x=spawn_x,
|
||||||
y=spawn_y,
|
y=spawn_y,
|
||||||
piece_type=piece_type,
|
piece_type=piece_type,
|
||||||
|
|
@ -427,6 +451,7 @@ class GameEngine:
|
||||||
self.game_started = False
|
self.game_started = False
|
||||||
# Regenerate fresh procedural mountain ranges and valley trenches on reset
|
# Regenerate fresh procedural mountain ranges and valley trenches on reset
|
||||||
self.obstacles = self._generate_terrain()
|
self.obstacles = self._generate_terrain()
|
||||||
|
self.wizard = self._spawn_wizard()
|
||||||
|
|
||||||
async def get_board_state(self) -> BoardState:
|
async def get_board_state(self) -> BoardState:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
|
@ -439,6 +464,7 @@ class GameEngine:
|
||||||
players=players_list,
|
players=players_list,
|
||||||
parties=parties_list,
|
parties=parties_list,
|
||||||
obstacles=obstacles_list,
|
obstacles=obstacles_list,
|
||||||
|
wizard=self.wizard,
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
game_started=self.game_started,
|
game_started=self.game_started,
|
||||||
conclusion=self._check_game_concluded(),
|
conclusion=self._check_game_concluded(),
|
||||||
|
|
@ -567,6 +593,17 @@ class GameEngine:
|
||||||
else:
|
else:
|
||||||
rec_act = "hunt_party"
|
rec_act = "hunt_party"
|
||||||
|
|
||||||
|
wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
|
||||||
|
wiz_radar = WizardRadarTarget(
|
||||||
|
id=self.wizard.id,
|
||||||
|
name=self.wizard.name,
|
||||||
|
x=self.wizard.x,
|
||||||
|
y=self.wizard.y,
|
||||||
|
distance=wiz_dist,
|
||||||
|
strength=self.wizard.strength,
|
||||||
|
can_challenge=(wiz_dist <= 1),
|
||||||
|
)
|
||||||
|
|
||||||
return BotRadarResponse(
|
return BotRadarResponse(
|
||||||
player_id=player.id,
|
player_id=player.id,
|
||||||
current_x=player.x,
|
current_x=player.x,
|
||||||
|
|
@ -574,6 +611,7 @@ class GameEngine:
|
||||||
bot_goal=bot_goal,
|
bot_goal=bot_goal,
|
||||||
targets=targets,
|
targets=targets,
|
||||||
nearest_target=nearest,
|
nearest_target=nearest,
|
||||||
|
wizard=wiz_radar,
|
||||||
recommended_direction=rec_dir,
|
recommended_direction=rec_dir,
|
||||||
recommended_action=rec_act,
|
recommended_action=rec_act,
|
||||||
)
|
)
|
||||||
|
|
@ -1130,6 +1168,19 @@ class GameEngine:
|
||||||
killed_leader.is_party_leader = False
|
killed_leader.is_party_leader = False
|
||||||
respawn_pos = {"x": respawn_x, "y": respawn_y}
|
respawn_pos = {"x": respawn_x, "y": respawn_y}
|
||||||
|
|
||||||
|
# Defeated party members (including leader) all lose 1 to 3 health points (randomized)
|
||||||
|
health_losses: Dict[str, int] = {}
|
||||||
|
defeated_all_ids = list(defeated_party.member_ids)
|
||||||
|
if defeated_party.leader_id and defeated_party.leader_id not in defeated_all_ids:
|
||||||
|
defeated_all_ids.append(defeated_party.leader_id)
|
||||||
|
|
||||||
|
for mid in defeated_all_ids:
|
||||||
|
p = self.players.get(mid)
|
||||||
|
if p:
|
||||||
|
hp_loss = random.randint(1, 3)
|
||||||
|
p.health = max(0, p.health - hp_loss)
|
||||||
|
health_losses[mid] = hp_loss
|
||||||
|
|
||||||
for mid in list(defeated_party.member_ids):
|
for mid in list(defeated_party.member_ids):
|
||||||
if mid != defeated_party.leader_id:
|
if mid != defeated_party.leader_id:
|
||||||
m = self.players.get(mid)
|
m = self.players.get(mid)
|
||||||
|
|
@ -1166,6 +1217,7 @@ class GameEngine:
|
||||||
absorbed_members=absorbed_members,
|
absorbed_members=absorbed_members,
|
||||||
new_party_size=len(winner_party.member_ids),
|
new_party_size=len(winner_party.member_ids),
|
||||||
new_party_strength=winner_party.total_strength,
|
new_party_strength=winner_party.total_strength,
|
||||||
|
health_losses=health_losses,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
||||||
|
|
@ -1221,6 +1273,133 @@ class GameEngine:
|
||||||
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
||||||
return await self.fight_battle(challenger_id, defender_id)
|
return await self.fight_battle(challenger_id, defender_id)
|
||||||
|
|
||||||
|
def _resolve_wizard_challenge_internal(self, player: Player) -> WizardChallengeResult:
|
||||||
|
"""Resolve a 3-bout D20 challenge between player/party and the Wizard NPC."""
|
||||||
|
effective_strength = player.strength
|
||||||
|
party_id = player.party_id
|
||||||
|
if party_id and party_id in self.parties:
|
||||||
|
party = self.parties[party_id]
|
||||||
|
self._update_party_strength(party)
|
||||||
|
effective_strength = party.total_strength
|
||||||
|
|
||||||
|
bouts: List[WizardChallengeBout] = []
|
||||||
|
player_bouts_won = 0
|
||||||
|
wizard_bouts_won = 0
|
||||||
|
|
||||||
|
for bout_idx in range(1, 4):
|
||||||
|
r_player = random.randint(1, 20)
|
||||||
|
r_wiz = random.randint(1, 20)
|
||||||
|
score_player = round(effective_strength * r_player, 1)
|
||||||
|
score_wiz = round(self.wizard.strength * r_wiz, 1)
|
||||||
|
|
||||||
|
if score_player > score_wiz:
|
||||||
|
winner = "player"
|
||||||
|
player_bouts_won += 1
|
||||||
|
elif score_wiz > score_player:
|
||||||
|
winner = "wizard"
|
||||||
|
wizard_bouts_won += 1
|
||||||
|
else:
|
||||||
|
if effective_strength >= self.wizard.strength:
|
||||||
|
winner = "player"
|
||||||
|
player_bouts_won += 1
|
||||||
|
else:
|
||||||
|
winner = "wizard"
|
||||||
|
wizard_bouts_won += 1
|
||||||
|
|
||||||
|
bouts.append(
|
||||||
|
WizardChallengeBout(
|
||||||
|
bout_number=bout_idx,
|
||||||
|
player_roll=r_player,
|
||||||
|
player_strength=effective_strength,
|
||||||
|
player_score=score_player,
|
||||||
|
wizard_roll=r_wiz,
|
||||||
|
wizard_strength=self.wizard.strength,
|
||||||
|
wizard_score=score_wiz,
|
||||||
|
winner=winner,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
player_won = player_bouts_won >= 2
|
||||||
|
score_change = 0
|
||||||
|
health_change = 0
|
||||||
|
|
||||||
|
if player_won:
|
||||||
|
# Player wins challenge: +2 score
|
||||||
|
player.score += 2
|
||||||
|
score_change = 2
|
||||||
|
health_change = 0
|
||||||
|
else:
|
||||||
|
# Player loses challenge: lose 2 health (or points if they do not have health to lose)
|
||||||
|
if player.health >= 2:
|
||||||
|
player.health -= 2
|
||||||
|
health_change = -2
|
||||||
|
score_change = 0
|
||||||
|
elif player.health > 0:
|
||||||
|
pts_lost = 2 - player.health
|
||||||
|
health_change = -player.health
|
||||||
|
player.health = 0
|
||||||
|
player.score -= pts_lost
|
||||||
|
score_change = -pts_lost
|
||||||
|
else:
|
||||||
|
health_change = 0
|
||||||
|
player.score -= 2
|
||||||
|
score_change = -2
|
||||||
|
|
||||||
|
# Wizard teleports to a new random open coordinate on the map
|
||||||
|
new_wx, new_wy = self._find_random_free_position()
|
||||||
|
self.wizard.x = new_wx
|
||||||
|
self.wizard.y = new_wy
|
||||||
|
respawn_pos = {"x": new_wx, "y": new_wy}
|
||||||
|
|
||||||
|
self._advance_turn()
|
||||||
|
|
||||||
|
return WizardChallengeResult(
|
||||||
|
challenger_id=player.id,
|
||||||
|
challenger_name=player.name,
|
||||||
|
party_id=party_id,
|
||||||
|
bouts=bouts,
|
||||||
|
player_bouts_won=player_bouts_won,
|
||||||
|
wizard_bouts_won=wizard_bouts_won,
|
||||||
|
player_won=player_won,
|
||||||
|
score_change=score_change,
|
||||||
|
health_change=health_change,
|
||||||
|
new_score=player.score,
|
||||||
|
new_health=player.health,
|
||||||
|
wizard_respawn_position=respawn_pos,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def challenge_wizard(self, player_id: str) -> WizardChallengeResult:
|
||||||
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
|
|
||||||
|
player = self.players.get(player_id)
|
||||||
|
if not player:
|
||||||
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
||||||
|
current_turn_player = self._get_current_player()
|
||||||
|
if not current_turn_player or current_turn_player.id != player_id:
|
||||||
|
curr_name = current_turn_player.name if current_turn_player else "Nobody"
|
||||||
|
curr_id = current_turn_player.id if current_turn_player else "None"
|
||||||
|
raise PermissionError(
|
||||||
|
f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})."
|
||||||
|
)
|
||||||
|
|
||||||
|
if player.party_id and not player.is_party_leader:
|
||||||
|
party = self.parties.get(player.party_id)
|
||||||
|
leader_name = party.leader_name if party else "Leader"
|
||||||
|
raise PermissionError(
|
||||||
|
f"Party member '{player.name}' cannot challenge the wizard individually. Only party leader '{leader_name}' can initiate challenges."
|
||||||
|
)
|
||||||
|
|
||||||
|
dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
|
||||||
|
if dist > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Player '{player.name}' is not adjacent to the Grand Wizard (distance {dist}). Must be within 1 distance."
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._resolve_wizard_challenge_internal(player)
|
||||||
|
|
||||||
async def get_game_conclusion(self) -> GameConclusion:
|
async def get_game_conclusion(self) -> GameConclusion:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return self._check_game_concluded()
|
return self._check_game_concluded()
|
||||||
|
|
@ -1517,10 +1696,34 @@ class GameEngine:
|
||||||
move_result=None,
|
move_result=None,
|
||||||
formed_party=formed_party,
|
formed_party=formed_party,
|
||||||
battle_result=None,
|
battle_result=None,
|
||||||
|
wizard_challenge_result=None,
|
||||||
game_concluded=conclusion if conclusion.concluded else None,
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 1b. Check if adjacent to the Grand Wizard NPC and choose to challenge
|
||||||
|
wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
|
||||||
|
if wiz_dist <= 1 and (not player.party_id or player.is_party_leader):
|
||||||
|
effective_str = player.strength
|
||||||
|
if player.party_id and player.party_id in self.parties:
|
||||||
|
effective_str = self.parties[player.party_id].total_strength
|
||||||
|
if effective_str >= self.wizard.strength or player.health >= 4:
|
||||||
|
challenge_res = self._resolve_wizard_challenge_internal(player)
|
||||||
|
conclusion = self._check_game_concluded()
|
||||||
|
return AiStepResponse(
|
||||||
|
action_taken="challenged_wizard",
|
||||||
|
player_id=player.id,
|
||||||
|
player_name=player.name,
|
||||||
|
bot_goal=bot_goal,
|
||||||
|
direction=None,
|
||||||
|
move_result=None,
|
||||||
|
formed_party=None,
|
||||||
|
battle_result=None,
|
||||||
|
wizard_challenge_result=challenge_res,
|
||||||
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
|
turn=self._get_turn_info(),
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Navigate towards target using BFS pathfinder & memory
|
# 2. Navigate towards target using BFS pathfinder & memory
|
||||||
occupied = self._get_occupied_coordinates()
|
occupied = self._get_occupied_coordinates()
|
||||||
moves_map: Dict[str, MoveCheckResult] = {}
|
moves_map: Dict[str, MoveCheckResult] = {}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ class PlayerCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
||||||
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
||||||
strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)")
|
strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)")
|
||||||
|
health: Optional[int] = Field(default=10, ge=1, description="Starting health points (default is 10)")
|
||||||
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
|
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
|
|
@ -148,6 +149,8 @@ class Player(BaseModel):
|
||||||
y: int
|
y: int
|
||||||
strength: float = 1.0
|
strength: float = 1.0
|
||||||
score: int = 0
|
score: int = 0
|
||||||
|
health: int = 10
|
||||||
|
max_health: int = 10
|
||||||
piece_type: Optional[str] = "knight"
|
piece_type: Optional[str] = "knight"
|
||||||
party_id: Optional[str] = None
|
party_id: Optional[str] = None
|
||||||
is_party_leader: bool = False
|
is_party_leader: bool = False
|
||||||
|
|
@ -243,6 +246,10 @@ class BattleResult(BaseModel):
|
||||||
)
|
)
|
||||||
new_party_size: int
|
new_party_size: int
|
||||||
new_party_strength: float
|
new_party_strength: float
|
||||||
|
health_losses: Dict[str, int] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BattleRequest(BaseModel):
|
class BattleRequest(BaseModel):
|
||||||
|
|
@ -250,6 +257,61 @@ class BattleRequest(BaseModel):
|
||||||
defender_id: str
|
defender_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Wizard NPC & Challenge Models
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
class WizardNPC(BaseModel):
|
||||||
|
id: str = "wizard_npc"
|
||||||
|
name: str = "Grand Wizard"
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
strength: float = 3.0
|
||||||
|
color: str = "#A855F7"
|
||||||
|
dialogue: Optional[str] = "Greetings, traveler! Do you dare challenge my arcane arts?"
|
||||||
|
|
||||||
|
|
||||||
|
class WizardChallengeBout(BaseModel):
|
||||||
|
bout_number: int
|
||||||
|
player_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for player")
|
||||||
|
player_strength: float
|
||||||
|
player_score: float = Field(..., description="player_strength * player_roll")
|
||||||
|
wizard_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for wizard")
|
||||||
|
wizard_strength: float
|
||||||
|
wizard_score: float = Field(..., description="wizard_strength * wizard_roll")
|
||||||
|
winner: str = Field(..., description="'player' or 'wizard'")
|
||||||
|
|
||||||
|
|
||||||
|
class WizardChallengeResult(BaseModel):
|
||||||
|
challenger_id: str
|
||||||
|
challenger_name: str
|
||||||
|
wizard_name: str = "Grand Wizard"
|
||||||
|
party_id: Optional[str] = None
|
||||||
|
bouts: List[WizardChallengeBout]
|
||||||
|
player_bouts_won: int
|
||||||
|
wizard_bouts_won: int
|
||||||
|
player_won: bool
|
||||||
|
score_change: int = 0
|
||||||
|
health_change: int = 0
|
||||||
|
new_score: int
|
||||||
|
new_health: int
|
||||||
|
wizard_respawn_position: Dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WizardChallengeRequest(BaseModel):
|
||||||
|
player_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class WizardRadarTarget(BaseModel):
|
||||||
|
id: str = "wizard_npc"
|
||||||
|
name: str = "Grand Wizard"
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
distance: int
|
||||||
|
strength: float = 3.0
|
||||||
|
can_challenge: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Memory & Radar Models
|
# Memory & Radar Models
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -287,8 +349,9 @@ class BotRadarResponse(BaseModel):
|
||||||
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
||||||
targets: List[RadarTarget]
|
targets: List[RadarTarget]
|
||||||
nearest_target: Optional[RadarTarget] = None
|
nearest_target: Optional[RadarTarget] = None
|
||||||
|
wizard: Optional[WizardRadarTarget] = None
|
||||||
recommended_direction: Optional[str] = None
|
recommended_direction: Optional[str] = None
|
||||||
recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited"
|
recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited", "challenge_wizard"
|
||||||
|
|
||||||
|
|
||||||
class BoardConfig(BaseModel):
|
class BoardConfig(BaseModel):
|
||||||
|
|
@ -329,6 +392,7 @@ class BoardState(BaseModel):
|
||||||
players: List[Player]
|
players: List[Player]
|
||||||
parties: List[Party] = []
|
parties: List[Party] = []
|
||||||
obstacles: List[Obstacle] = Field(default_factory=list)
|
obstacles: List[Obstacle] = Field(default_factory=list)
|
||||||
|
wizard: Optional[WizardNPC] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
game_started: bool = False
|
game_started: bool = False
|
||||||
conclusion: Optional[GameConclusion] = None
|
conclusion: Optional[GameConclusion] = None
|
||||||
|
|
@ -351,7 +415,7 @@ class MoveResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AiStepResponse(BaseModel):
|
class AiStepResponse(BaseModel):
|
||||||
action_taken: str # "formed_party", "battled", "moved", "passed"
|
action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed"
|
||||||
player_id: str
|
player_id: str
|
||||||
player_name: str
|
player_name: str
|
||||||
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
||||||
|
|
@ -359,6 +423,7 @@ class AiStepResponse(BaseModel):
|
||||||
move_result: Optional[MoveResponse] = None
|
move_result: Optional[MoveResponse] = None
|
||||||
formed_party: Optional[Party] = None
|
formed_party: Optional[Party] = None
|
||||||
battle_result: Optional[BattleResult] = None
|
battle_result: Optional[BattleResult] = None
|
||||||
|
wizard_challenge_result: Optional[WizardChallengeResult] = None
|
||||||
game_concluded: Optional[GameConclusion] = None
|
game_concluded: Optional[GameConclusion] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,34 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
assert battle_data["bouts"][0]["party1_strength"] == 10
|
assert battle_data["bouts"][0]["party1_strength"] == 10
|
||||||
assert battle_data["bouts"][0]["party2_strength"] == 2
|
assert battle_data["bouts"][0]["party2_strength"] == 2
|
||||||
|
|
||||||
|
# Verify losing party members (leader BetaLead + follower BetaWing) each lost 1-3 health points
|
||||||
|
assert "health_losses" in battle_data
|
||||||
|
assert p3["id"] in battle_data["health_losses"]
|
||||||
|
assert p4["id"] in battle_data["health_losses"]
|
||||||
|
assert 1 <= battle_data["health_losses"][p3["id"]] <= 3
|
||||||
|
assert 1 <= battle_data["health_losses"][p4["id"]] <= 3
|
||||||
|
|
||||||
|
# Check updated health of defeated members
|
||||||
|
b3_after = client.get(f"/api/players/{p3['id']}").json()
|
||||||
|
b4_after = client.get(f"/api/players/{p4['id']}").json()
|
||||||
|
assert b3_after["health"] == 10 - battle_data["health_losses"][p3["id"]]
|
||||||
|
assert b4_after["health"] == 10 - battle_data["health_losses"][p4["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_health_default_and_custom_registration():
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
|
# Default health should be 10
|
||||||
|
p_default = client.post("/api/players", json={"name": "DefaultHPBot", "color": "#123456", "strength": 3}).json()
|
||||||
|
assert p_default["health"] == 10
|
||||||
|
assert p_default["max_health"] == 10
|
||||||
|
|
||||||
|
# Custom health (e.g. 18)
|
||||||
|
p_custom = client.post("/api/players", json={"name": "TankHPBot", "color": "#654321", "strength": 4, "health": 18}).json()
|
||||||
|
assert p_custom["health"] == 18
|
||||||
|
assert p_custom["max_health"] == 18
|
||||||
|
|
||||||
|
|
||||||
def test_game_conclusion_and_scoreboard():
|
def test_game_conclusion_and_scoreboard():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
@ -445,3 +473,124 @@ def test_party_movement_forms_line():
|
||||||
# Distances are all <= 1
|
# Distances are all <= 1
|
||||||
assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1
|
assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1
|
||||||
assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1
|
assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_wizard_npc_existence_and_radar_detection():
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
|
# 1. Check GET /api/wizard
|
||||||
|
wiz_res = client.get("/api/wizard")
|
||||||
|
assert wiz_res.status_code == 200
|
||||||
|
wiz_data = wiz_res.json()
|
||||||
|
assert wiz_data["id"] == "wizard_npc"
|
||||||
|
assert wiz_data["name"] == "Grand Wizard"
|
||||||
|
assert wiz_data["strength"] == 3.0
|
||||||
|
assert 0 <= wiz_data["x"] <= 64
|
||||||
|
assert 0 <= wiz_data["y"] <= 64
|
||||||
|
|
||||||
|
# 2. Check BoardState includes wizard
|
||||||
|
board_res = client.get("/api/board").json()
|
||||||
|
assert board_res["wizard"] is not None
|
||||||
|
assert board_res["wizard"]["id"] == "wizard_npc"
|
||||||
|
|
||||||
|
# 3. Register a bot and check radar includes wizard target
|
||||||
|
p = client.post("/api/players", json={"name": "RadarExplorer", "color": "#123456", "strength": 4}).json()
|
||||||
|
radar_res = client.get(f"/api/players/{p['id']}/radar").json()
|
||||||
|
assert "wizard" in radar_res
|
||||||
|
assert radar_res["wizard"] is not None
|
||||||
|
assert radar_res["wizard"]["id"] == "wizard_npc"
|
||||||
|
assert radar_res["wizard"]["strength"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_wizard_challenge_mechanics_victory_and_defeat():
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
|
p1 = client.post("/api/players", json={"name": "ChallengerBot", "color": "#38bdf8", "strength": 50}).json()
|
||||||
|
p2 = client.post("/api/players", json={"name": "WeakChallenger", "color": "#f43f5e", "strength": 1}).json()
|
||||||
|
|
||||||
|
# Move p1 adjacent to wizard
|
||||||
|
wiz = client.get("/api/wizard").json()
|
||||||
|
wx, wy = wiz["x"], wiz["y"]
|
||||||
|
|
||||||
|
async def setup_wizard_adjacent():
|
||||||
|
# Place p1 adjacent to wizard (e.g. wx+1, wy if within bounds)
|
||||||
|
adj_x = wx + 1 if wx < 64 else wx - 1
|
||||||
|
adj_y = wy
|
||||||
|
bot1 = await game_engine.get_player(p1["id"])
|
||||||
|
bot1.x = adj_x
|
||||||
|
bot1.y = adj_y
|
||||||
|
bot1.health = 10
|
||||||
|
bot1.score = 0
|
||||||
|
asyncio.run(setup_wizard_adjacent())
|
||||||
|
|
||||||
|
client.post("/api/game/start")
|
||||||
|
|
||||||
|
# Set turn to ChallengerBot
|
||||||
|
game_engine.turn_order = [p1["id"], p2["id"]]
|
||||||
|
game_engine.current_turn_index = 0
|
||||||
|
|
||||||
|
# 1. Challenge the wizard with super high strength (strength 50 guaranteed win)
|
||||||
|
chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"]})
|
||||||
|
assert chal_res.status_code == 200
|
||||||
|
res_data = chal_res.json()
|
||||||
|
assert len(res_data["bouts"]) == 3
|
||||||
|
assert res_data["player_won"] is True
|
||||||
|
assert res_data["score_change"] == 2
|
||||||
|
assert res_data["new_score"] == 2
|
||||||
|
assert res_data["new_health"] == 10
|
||||||
|
|
||||||
|
# Wizard teleports to a new location
|
||||||
|
wiz_after = client.get("/api/wizard").json()
|
||||||
|
assert (wiz_after["x"], wiz_after["y"]) == (res_data["wizard_respawn_position"]["x"], res_data["wizard_respawn_position"]["y"])
|
||||||
|
|
||||||
|
# 2. Test defeat case: bot loses 2 health
|
||||||
|
# Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss)
|
||||||
|
async def setup_weak_loss():
|
||||||
|
new_wx, new_wy = wiz_after["x"], wiz_after["y"]
|
||||||
|
adj_x = new_wx + 1 if new_wx < 64 else new_wx - 1
|
||||||
|
adj_y = new_wy
|
||||||
|
bot2 = await game_engine.get_player(p2["id"])
|
||||||
|
bot2.x = adj_x
|
||||||
|
bot2.y = adj_y
|
||||||
|
bot2.strength = 0.001
|
||||||
|
bot2.health = 10
|
||||||
|
bot2.score = 5
|
||||||
|
asyncio.run(setup_weak_loss())
|
||||||
|
|
||||||
|
game_engine.current_turn_index = 1 # p2's turn
|
||||||
|
loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
|
||||||
|
assert loss_res.status_code == 200
|
||||||
|
loss_data = loss_res.json()
|
||||||
|
assert loss_data["player_won"] is False
|
||||||
|
assert loss_data["health_change"] == -2
|
||||||
|
assert loss_data["new_health"] == 8
|
||||||
|
assert loss_data["new_score"] == 5
|
||||||
|
|
||||||
|
# 3. Test defeat when health is 0: bot loses 2 points (score)
|
||||||
|
async def setup_zero_health():
|
||||||
|
cur_wiz = client.get("/api/wizard").json()
|
||||||
|
adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1
|
||||||
|
adj_y = cur_wiz["y"]
|
||||||
|
bot2 = await game_engine.get_player(p2["id"])
|
||||||
|
bot2.x = adj_x
|
||||||
|
bot2.y = adj_y
|
||||||
|
bot2.strength = 0.001
|
||||||
|
bot2.health = 0 # no health to lose
|
||||||
|
bot2.score = 5
|
||||||
|
asyncio.run(setup_zero_health())
|
||||||
|
|
||||||
|
# Set turn back to p2
|
||||||
|
game_engine.current_turn_index = 0
|
||||||
|
game_engine.turn_order = [p2["id"]]
|
||||||
|
|
||||||
|
score_loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
|
||||||
|
assert score_loss_res.status_code == 200
|
||||||
|
score_loss_data = score_loss_res.json()
|
||||||
|
assert score_loss_data["player_won"] is False
|
||||||
|
assert score_loss_data["health_change"] == 0
|
||||||
|
assert score_loss_data["score_change"] == -2
|
||||||
|
assert score_loss_data["new_health"] == 0
|
||||||
|
assert score_loss_data["new_score"] == 3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,5 @@ python3 bot_agent.py --name BloodAxe --color "#f43f5e" -s 3 --piece-type warrior
|
||||||
| `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot |
|
| `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot |
|
||||||
| `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar |
|
| `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar |
|
||||||
| `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) |
|
| `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) |
|
||||||
|
| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) |
|
||||||
| | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` |
|
| | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` |
|
||||||
|
|
@ -18,6 +18,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
|
||||||
DEFAULT_BOT_NAME = "ExternalCyberBot"
|
DEFAULT_BOT_NAME = "ExternalCyberBot"
|
||||||
DEFAULT_BOT_COLOR = "#10b981"
|
DEFAULT_BOT_COLOR = "#10b981"
|
||||||
DEFAULT_BOT_STRENGTH = 4
|
DEFAULT_BOT_STRENGTH = 4
|
||||||
|
DEFAULT_BOT_HEALTH = 10
|
||||||
|
|
||||||
|
|
||||||
def normalize_url(url: str) -> str:
|
def normalize_url(url: str) -> str:
|
||||||
|
|
@ -34,12 +35,14 @@ class SmartBotAgent:
|
||||||
name: str = DEFAULT_BOT_NAME,
|
name: str = DEFAULT_BOT_NAME,
|
||||||
color: str = DEFAULT_BOT_COLOR,
|
color: str = DEFAULT_BOT_COLOR,
|
||||||
strength: int = DEFAULT_BOT_STRENGTH,
|
strength: int = DEFAULT_BOT_STRENGTH,
|
||||||
|
health: int = DEFAULT_BOT_HEALTH,
|
||||||
server_url: str = DEFAULT_SERVER_URL,
|
server_url: str = DEFAULT_SERVER_URL,
|
||||||
piece_type: Optional[str] = None,
|
piece_type: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.color = color
|
self.color = color
|
||||||
self.strength = strength
|
self.strength = strength
|
||||||
|
self.health = health
|
||||||
self.server_url = server_url
|
self.server_url = server_url
|
||||||
self.piece_type = piece_type
|
self.piece_type = piece_type
|
||||||
self.base_url = normalize_url(server_url)
|
self.base_url = normalize_url(server_url)
|
||||||
|
|
@ -54,12 +57,17 @@ class SmartBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
payload = {
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"strength": self.strength,
|
||||||
|
"health": self.health,
|
||||||
|
}
|
||||||
if self.piece_type:
|
if self.piece_type:
|
||||||
payload["piece_type"] = self.piece_type
|
payload["piece_type"] = self.piece_type
|
||||||
|
|
||||||
|
|
@ -72,13 +80,13 @@ class SmartBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
|
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
self.bot_id = data["id"]
|
self.bot_id = data["id"]
|
||||||
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})")
|
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
|
||||||
|
|
||||||
def refresh_status(self):
|
def refresh_status(self):
|
||||||
"""Update bot state (party membership, leader status, score)."""
|
"""Update bot state (party membership, leader status, score)."""
|
||||||
|
|
@ -102,6 +110,7 @@ class SmartBotAgent:
|
||||||
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
||||||
targets = radar_res.get("targets", [])
|
targets = radar_res.get("targets", [])
|
||||||
nearest = radar_res.get("nearest_target")
|
nearest = radar_res.get("nearest_target")
|
||||||
|
wizard = radar_res.get("wizard")
|
||||||
|
|
||||||
# 2. Check for immediate adjacent interaction (distance <= 1)
|
# 2. Check for immediate adjacent interaction (distance <= 1)
|
||||||
adjacent_target = None
|
adjacent_target = None
|
||||||
|
|
@ -114,7 +123,17 @@ class SmartBotAgent:
|
||||||
self._handle_adjacent_encounter(adjacent_target)
|
self._handle_adjacent_encounter(adjacent_target)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. If no immediate adjacent enemy/recruit, move towards target
|
# 3. Check for Wizard NPC encounter (voluntary challenge)
|
||||||
|
if wizard and wizard.get("can_challenge"):
|
||||||
|
my_health = my_info.get("health", 10)
|
||||||
|
wiz_str = wizard.get("strength", 3.0)
|
||||||
|
# Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4)
|
||||||
|
if self.strength >= wiz_str or my_health >= 4:
|
||||||
|
print(f"🧙 [WIZARD NEARBY] Adjacent to {wizard.get('name', 'Grand Wizard')} (Str: {wiz_str})! HP: {my_health}, Bot Str: {self.strength}. Choosing to challenge!")
|
||||||
|
self._challenge_wizard()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. If no immediate adjacent enemy/recruit, move towards target
|
||||||
self._navigate_towards_goal(radar_res)
|
self._navigate_towards_goal(radar_res)
|
||||||
|
|
||||||
def _handle_adjacent_encounter(self, target: Dict[str, Any]):
|
def _handle_adjacent_encounter(self, target: Dict[str, Any]):
|
||||||
|
|
@ -213,6 +232,31 @@ class SmartBotAgent:
|
||||||
else:
|
else:
|
||||||
print(f"Battle failed ({res.status_code}): {res.text}")
|
print(f"Battle failed ({res.status_code}): {res.text}")
|
||||||
|
|
||||||
|
def _challenge_wizard(self):
|
||||||
|
"""Voluntarily challenge the Wizard NPC to a 3-bout D20 duel."""
|
||||||
|
print(f"🧙 [WIZARD CHALLENGE] Challenging Grand Wizard to a 3-bout D20 duel...")
|
||||||
|
try:
|
||||||
|
res = requests.post(
|
||||||
|
f"{self.base_url}/wizard/challenge",
|
||||||
|
json={"player_id": self.bot_id},
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
result = res.json()
|
||||||
|
outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
|
||||||
|
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
|
||||||
|
for b in result.get("bouts", []):
|
||||||
|
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
|
||||||
|
print(f" Score Change: {result.get('score_change')} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Score: {result.get('new_score')}")
|
||||||
|
pos = result.get("wizard_respawn_position")
|
||||||
|
if pos:
|
||||||
|
print(f" 🔮 Wizard teleported to ({pos.get('x')}, {pos.get('y')})")
|
||||||
|
else:
|
||||||
|
print(f"Wizard challenge failed ({res.status_code}): {res.text}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error challenging wizard: {e}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
|
||||||
def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]:
|
def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Pick the available direction that minimizes Chebyshev distance to (target_x, target_y), strictly avoiding obstacles."""
|
"""Pick the available direction that minimizes Chebyshev distance to (target_x, target_y), strictly avoiding obstacles."""
|
||||||
valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")}
|
valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")}
|
||||||
|
|
@ -315,6 +359,7 @@ def main():
|
||||||
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
||||||
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
||||||
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
||||||
|
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
|
||||||
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
@ -346,6 +391,13 @@ def main():
|
||||||
default=env_strength,
|
default=env_strength,
|
||||||
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)",
|
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-H", "--health",
|
||||||
|
dest="health",
|
||||||
|
type=int,
|
||||||
|
default=env_health,
|
||||||
|
help="Health points attribute (default 10) for the bot (env: BOT_HEALTH)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--piece-type",
|
"--piece-type",
|
||||||
dest="piece_type",
|
dest="piece_type",
|
||||||
|
|
@ -360,6 +412,7 @@ def main():
|
||||||
name=args.name,
|
name=args.name,
|
||||||
color=args.color,
|
color=args.color,
|
||||||
strength=args.strength,
|
strength=args.strength,
|
||||||
|
health=args.health,
|
||||||
server_url=args.server_url,
|
server_url=args.server_url,
|
||||||
piece_type=args.piece_type,
|
piece_type=args.piece_type,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,13 @@ Configure it via environment variables or CLI flags:
|
||||||
- `BOT_NAME` / `-n`: Bot display name
|
- `BOT_NAME` / `-n`: Bot display name
|
||||||
- `BOT_COLOR` / `-c`: Hex color for the bot avatar
|
- `BOT_COLOR` / `-c`: Hex color for the bot avatar
|
||||||
- `BOT_STRENGTH` / `-s`: Starting strength (1-10)
|
- `BOT_STRENGTH` / `-s`: Starting strength (1-10)
|
||||||
|
- `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```bash
|
```bash
|
||||||
export OLLAMA_BASE_URL="http://192.168.1.220:11434"
|
export OLLAMA_BASE_URL="http://192.168.1.220:11434"
|
||||||
export OLLAMA_MODEL="gemma4:12b"
|
export OLLAMA_MODEL="gemma4:12b"
|
||||||
python bot.py -n MyAIBot -s 5
|
python bot.py -n MyAIBot -s 5 -H 10
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the Bot
|
## Running the Bot
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
|
||||||
DEFAULT_BOT_NAME = "OllamaBot"
|
DEFAULT_BOT_NAME = "OllamaBot"
|
||||||
DEFAULT_BOT_COLOR = "#8b5cf6"
|
DEFAULT_BOT_COLOR = "#8b5cf6"
|
||||||
DEFAULT_BOT_STRENGTH = 4
|
DEFAULT_BOT_STRENGTH = 4
|
||||||
|
DEFAULT_BOT_HEALTH = 10
|
||||||
|
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b")
|
||||||
|
|
@ -35,8 +36,11 @@ Rules you must respect when choosing among the OPTIONS given to you:
|
||||||
score if tied) leads. Larger parties have an advantage in battle.
|
score if tied) leads. Larger parties have an advantage in battle.
|
||||||
- A solo bot always joins a party if the party leader's strength >= its own (no choice).
|
- A solo bot always joins a party if the party leader's strength >= its own (no choice).
|
||||||
- A solo bot always refuses and fights if the party leader is weaker (no choice).
|
- A solo bot always refuses and fights if the party leader is weaker (no choice).
|
||||||
- Two opposing parties that meet must always battle (no choice).
|
- Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
|
||||||
|
lose 1-3 health points (randomized).
|
||||||
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
|
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
|
||||||
|
- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
|
||||||
|
is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health).
|
||||||
- The game ends when all bots are united into a single party.
|
- The game ends when all bots are united into a single party.
|
||||||
You will only ever be asked to choose between options that are legal - always answer with the
|
You will only ever be asked to choose between options that are legal - always answer with the
|
||||||
requested JSON object and nothing else.
|
requested JSON object and nothing else.
|
||||||
|
|
@ -95,6 +99,7 @@ class AIBotAgent:
|
||||||
name: str = DEFAULT_BOT_NAME,
|
name: str = DEFAULT_BOT_NAME,
|
||||||
color: str = DEFAULT_BOT_COLOR,
|
color: str = DEFAULT_BOT_COLOR,
|
||||||
strength: int = DEFAULT_BOT_STRENGTH,
|
strength: int = DEFAULT_BOT_STRENGTH,
|
||||||
|
health: int = DEFAULT_BOT_HEALTH,
|
||||||
server_url: str = DEFAULT_SERVER_URL,
|
server_url: str = DEFAULT_SERVER_URL,
|
||||||
ollama_url: str = OLLAMA_BASE_URL,
|
ollama_url: str = OLLAMA_BASE_URL,
|
||||||
ollama_model: str = OLLAMA_MODEL,
|
ollama_model: str = OLLAMA_MODEL,
|
||||||
|
|
@ -103,6 +108,7 @@ class AIBotAgent:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.color = color
|
self.color = color
|
||||||
self.strength = strength
|
self.strength = strength
|
||||||
|
self.health = health
|
||||||
self.piece_type = piece_type
|
self.piece_type = piece_type
|
||||||
self.base_url = normalize_url(server_url)
|
self.base_url = normalize_url(server_url)
|
||||||
self.llm = OllamaClient(ollama_url, ollama_model)
|
self.llm = OllamaClient(ollama_url, ollama_model)
|
||||||
|
|
@ -120,12 +126,17 @@ class AIBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
payload = {
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"strength": self.strength,
|
||||||
|
"health": self.health,
|
||||||
|
}
|
||||||
if self.piece_type:
|
if self.piece_type:
|
||||||
payload["piece_type"] = self.piece_type
|
payload["piece_type"] = self.piece_type
|
||||||
|
|
||||||
|
|
@ -138,13 +149,13 @@ class AIBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
|
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
self.bot_id = data["id"]
|
self.bot_id = data["id"]
|
||||||
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})")
|
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
|
||||||
|
|
||||||
def refresh_status(self):
|
def refresh_status(self):
|
||||||
"""Update bot state (party membership, leader status, score)."""
|
"""Update bot state (party membership, leader status, score)."""
|
||||||
|
|
@ -168,6 +179,7 @@ class AIBotAgent:
|
||||||
|
|
||||||
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
||||||
targets = radar_res.get("targets", [])
|
targets = radar_res.get("targets", [])
|
||||||
|
wizard = radar_res.get("wizard")
|
||||||
|
|
||||||
adjacent_target = None
|
adjacent_target = None
|
||||||
for t in targets:
|
for t in targets:
|
||||||
|
|
@ -177,9 +189,58 @@ class AIBotAgent:
|
||||||
|
|
||||||
if adjacent_target:
|
if adjacent_target:
|
||||||
self._handle_adjacent_encounter(adjacent_target, my_info)
|
self._handle_adjacent_encounter(adjacent_target, my_info)
|
||||||
|
elif wizard and wizard.get("can_challenge"):
|
||||||
|
if self._decide_wizard_challenge(wizard, my_info):
|
||||||
|
self._challenge_wizard(wizard)
|
||||||
|
else:
|
||||||
|
self._navigate_towards_goal(radar_res, my_info)
|
||||||
else:
|
else:
|
||||||
self._navigate_towards_goal(radar_res, my_info)
|
self._navigate_towards_goal(radar_res, my_info)
|
||||||
|
|
||||||
|
def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> bool:
|
||||||
|
"""Ask the LLM whether to challenge the adjacent Wizard NPC."""
|
||||||
|
prompt = f"""{GAME_RULES_SUMMARY}
|
||||||
|
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)})
|
||||||
|
at position ({my_info['x']}, {my_info['y']}).
|
||||||
|
You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}).
|
||||||
|
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
|
||||||
|
- If you win: +2 score points!
|
||||||
|
- If you lose: -2 health points (or -2 score if no health)!
|
||||||
|
Do you want to challenge the wizard to a duel?
|
||||||
|
|
||||||
|
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}}
|
||||||
|
"""
|
||||||
|
decision = self.llm.ask_json(prompt) or {}
|
||||||
|
challenge = decision.get("challenge_wizard", False)
|
||||||
|
reasoning = decision.get("reasoning", "")
|
||||||
|
print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge}. {reasoning}")
|
||||||
|
return bool(challenge)
|
||||||
|
|
||||||
|
def _challenge_wizard(self, wizard: Dict[str, Any]):
|
||||||
|
"""Execute the challenge against the Wizard NPC."""
|
||||||
|
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...")
|
||||||
|
try:
|
||||||
|
res = requests.post(
|
||||||
|
f"{self.base_url}/wizard/challenge",
|
||||||
|
json={"player_id": self.bot_id},
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
result = res.json()
|
||||||
|
outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
|
||||||
|
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
|
||||||
|
for b in result.get("bouts", []):
|
||||||
|
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
|
||||||
|
print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}")
|
||||||
|
pos = result.get("wizard_respawn_position")
|
||||||
|
if pos:
|
||||||
|
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})")
|
||||||
|
else:
|
||||||
|
print(f"Wizard challenge failed: {res.text}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error challenging wizard: {e}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
|
||||||
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
|
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
|
||||||
"""Resolve the encounter; the LLM only gets a say when the rules allow a choice."""
|
"""Resolve the encounter; the LLM only gets a say when the rules allow a choice."""
|
||||||
target_name = target["name"]
|
target_name = target["name"]
|
||||||
|
|
@ -373,6 +434,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
continue
|
continue
|
||||||
player_at.setdefault((p["x"], p["y"]), []).append(p)
|
player_at.setdefault((p["x"], p["y"]), []).append(p)
|
||||||
|
|
||||||
|
wizard = board.get("wizard")
|
||||||
|
wizard_pos = (wizard["x"], wizard["y"]) if wizard else None
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for y in range(center_y - radius, center_y + radius + 1):
|
for y in range(center_y - radius, center_y + radius + 1):
|
||||||
row_chars = []
|
row_chars = []
|
||||||
|
|
@ -381,6 +445,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
row_chars.append("@")
|
row_chars.append("@")
|
||||||
elif x < 0 or y < 0 or x >= max_x or y >= max_y:
|
elif x < 0 or y < 0 or x >= max_x or y >= max_y:
|
||||||
row_chars.append("#")
|
row_chars.append("#")
|
||||||
|
elif wizard_pos and (x, y) == wizard_pos:
|
||||||
|
row_chars.append("W")
|
||||||
elif (x, y) in obstacle_at:
|
elif (x, y) in obstacle_at:
|
||||||
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
|
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
|
||||||
elif (x, y) in player_at:
|
elif (x, y) in player_at:
|
||||||
|
|
@ -428,7 +494,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
map_section = f"""
|
map_section = f"""
|
||||||
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
|
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
|
||||||
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
|
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
|
||||||
M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
|
W = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
|
||||||
{chr(10).join(local_map)}
|
{chr(10).join(local_map)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -497,6 +563,7 @@ def main():
|
||||||
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
||||||
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
||||||
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
||||||
|
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
|
||||||
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
@ -511,6 +578,8 @@ def main():
|
||||||
help="Hex color code for the bot avatar (env: BOT_COLOR)")
|
help="Hex color code for the bot avatar (env: BOT_COLOR)")
|
||||||
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
|
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
|
||||||
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
|
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
|
||||||
|
parser.add_argument("-H", "--health", dest="health", type=int, default=env_health,
|
||||||
|
help="Starting health points (default 10) (env: BOT_HEALTH)")
|
||||||
parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type,
|
parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type,
|
||||||
help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)")
|
help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)")
|
||||||
parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL,
|
parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL,
|
||||||
|
|
@ -526,6 +595,7 @@ def main():
|
||||||
name=args.name,
|
name=args.name,
|
||||||
color=args.color,
|
color=args.color,
|
||||||
strength=args.strength,
|
strength=args.strength,
|
||||||
|
health=args.health,
|
||||||
server_url=args.server_url,
|
server_url=args.server_url,
|
||||||
ollama_url=args.ollama_url,
|
ollama_url=args.ollama_url,
|
||||||
ollama_model=args.ollama_model,
|
ollama_model=args.ollama_model,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ python3 bot.py --url "http://192.168.1.100:8000/api" --name "RemoteGear"
|
||||||
| `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid |
|
| `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid |
|
||||||
| `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar |
|
| `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar |
|
||||||
| `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) |
|
| `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) |
|
||||||
|
| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) |
|
||||||
| `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID |
|
| `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID |
|
||||||
| `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI |
|
| `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI |
|
||||||
| `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name |
|
| `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name |
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
|
||||||
DEFAULT_BOT_NAME = "GeminiGearBot"
|
DEFAULT_BOT_NAME = "GeminiGearBot"
|
||||||
DEFAULT_BOT_COLOR = "#4285f4"
|
DEFAULT_BOT_COLOR = "#4285f4"
|
||||||
DEFAULT_BOT_STRENGTH = 5
|
DEFAULT_BOT_STRENGTH = 5
|
||||||
|
DEFAULT_BOT_HEALTH = 10
|
||||||
DEFAULT_MODEL = "gemini-3.8-flash"
|
DEFAULT_MODEL = "gemini-3.8-flash"
|
||||||
DEFAULT_LOCATION = "us-central1"
|
DEFAULT_LOCATION = "us-central1"
|
||||||
|
|
||||||
|
|
@ -45,8 +46,11 @@ Rules you must respect when choosing among the OPTIONS given to you:
|
||||||
score if tied) leads. Larger parties have an advantage in battle.
|
score if tied) leads. Larger parties have an advantage in battle.
|
||||||
- A solo bot always joins a party if the party leader's strength >= its own (no choice).
|
- A solo bot always joins a party if the party leader's strength >= its own (no choice).
|
||||||
- A solo bot always refuses and fights if the party leader is weaker (no choice).
|
- A solo bot always refuses and fights if the party leader is weaker (no choice).
|
||||||
- Two opposing parties that meet must always battle (no choice).
|
- Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
|
||||||
|
lose 1-3 health points (randomized).
|
||||||
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
|
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
|
||||||
|
- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
|
||||||
|
is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health).
|
||||||
- The game ends when all bots are united into a single party.
|
- The game ends when all bots are united into a single party.
|
||||||
You will only ever be asked to choose between options that are legal - always answer with the
|
You will only ever be asked to choose between options that are legal - always answer with the
|
||||||
requested JSON object and nothing else.
|
requested JSON object and nothing else.
|
||||||
|
|
@ -274,6 +278,7 @@ class VertexAIBotAgent:
|
||||||
name: str = DEFAULT_BOT_NAME,
|
name: str = DEFAULT_BOT_NAME,
|
||||||
color: str = DEFAULT_BOT_COLOR,
|
color: str = DEFAULT_BOT_COLOR,
|
||||||
strength: int = DEFAULT_BOT_STRENGTH,
|
strength: int = DEFAULT_BOT_STRENGTH,
|
||||||
|
health: int = DEFAULT_BOT_HEALTH,
|
||||||
server_url: str = DEFAULT_SERVER_URL,
|
server_url: str = DEFAULT_SERVER_URL,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
location: str = DEFAULT_LOCATION,
|
location: str = DEFAULT_LOCATION,
|
||||||
|
|
@ -284,6 +289,7 @@ class VertexAIBotAgent:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.color = color
|
self.color = color
|
||||||
self.strength = strength
|
self.strength = strength
|
||||||
|
self.health = health
|
||||||
self.piece_type = piece_type
|
self.piece_type = piece_type
|
||||||
self.base_url = normalize_url(server_url)
|
self.base_url = normalize_url(server_url)
|
||||||
self.llm = VertexGeminiClient(
|
self.llm = VertexGeminiClient(
|
||||||
|
|
@ -306,12 +312,17 @@ class VertexAIBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
payload = {
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"strength": self.strength,
|
||||||
|
"health": self.health,
|
||||||
|
}
|
||||||
if self.piece_type:
|
if self.piece_type:
|
||||||
payload["piece_type"] = self.piece_type
|
payload["piece_type"] = self.piece_type
|
||||||
|
|
||||||
|
|
@ -324,13 +335,13 @@ class VertexAIBotAgent:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.get("name") == self.name:
|
if p.get("name") == self.name:
|
||||||
self.bot_id = p["id"]
|
self.bot_id = p["id"]
|
||||||
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
|
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
|
||||||
return
|
return
|
||||||
|
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
self.bot_id = data["id"]
|
self.bot_id = data["id"]
|
||||||
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})")
|
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
|
||||||
|
|
||||||
def refresh_status(self):
|
def refresh_status(self):
|
||||||
"""Update bot state (party membership, leader status, score)."""
|
"""Update bot state (party membership, leader status, score)."""
|
||||||
|
|
@ -354,6 +365,7 @@ class VertexAIBotAgent:
|
||||||
|
|
||||||
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
|
||||||
targets = radar_res.get("targets", [])
|
targets = radar_res.get("targets", [])
|
||||||
|
wizard = radar_res.get("wizard")
|
||||||
|
|
||||||
adjacent_target = None
|
adjacent_target = None
|
||||||
for t in targets:
|
for t in targets:
|
||||||
|
|
@ -363,9 +375,58 @@ class VertexAIBotAgent:
|
||||||
|
|
||||||
if adjacent_target:
|
if adjacent_target:
|
||||||
self._handle_adjacent_encounter(adjacent_target, my_info)
|
self._handle_adjacent_encounter(adjacent_target, my_info)
|
||||||
|
elif wizard and wizard.get("can_challenge"):
|
||||||
|
if self._decide_wizard_challenge(wizard, my_info):
|
||||||
|
self._challenge_wizard(wizard)
|
||||||
|
else:
|
||||||
|
self._navigate_towards_goal(radar_res, my_info)
|
||||||
else:
|
else:
|
||||||
self._navigate_towards_goal(radar_res, my_info)
|
self._navigate_towards_goal(radar_res, my_info)
|
||||||
|
|
||||||
|
def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> bool:
|
||||||
|
"""Ask Gemini whether to challenge the adjacent Wizard NPC."""
|
||||||
|
prompt = f"""{GAME_RULES_SUMMARY}
|
||||||
|
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)})
|
||||||
|
at position ({my_info['x']}, {my_info['y']}).
|
||||||
|
You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}).
|
||||||
|
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
|
||||||
|
- If you win: +2 score points!
|
||||||
|
- If you lose: -2 health points (or -2 score if no health)!
|
||||||
|
Do you want to challenge the wizard to a duel?
|
||||||
|
|
||||||
|
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}}
|
||||||
|
"""
|
||||||
|
decision = self.llm.ask_json(prompt) or {}
|
||||||
|
challenge = decision.get("challenge_wizard", False)
|
||||||
|
reasoning = decision.get("reasoning", "")
|
||||||
|
print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge}. {reasoning}")
|
||||||
|
return bool(challenge)
|
||||||
|
|
||||||
|
def _challenge_wizard(self, wizard: Dict[str, Any]):
|
||||||
|
"""Execute the challenge against the Wizard NPC."""
|
||||||
|
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...")
|
||||||
|
try:
|
||||||
|
res = requests.post(
|
||||||
|
f"{self.base_url}/wizard/challenge",
|
||||||
|
json={"player_id": self.bot_id},
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
result = res.json()
|
||||||
|
outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
|
||||||
|
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
|
||||||
|
for b in result.get("bouts", []):
|
||||||
|
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
|
||||||
|
print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}")
|
||||||
|
pos = result.get("wizard_respawn_position")
|
||||||
|
if pos:
|
||||||
|
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})")
|
||||||
|
else:
|
||||||
|
print(f"Wizard challenge failed: {res.text}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error challenging wizard: {e}")
|
||||||
|
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
|
||||||
|
|
||||||
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
|
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
|
||||||
"""Resolve the encounter; Gemini only gets a say when the rules allow a choice."""
|
"""Resolve the encounter; Gemini only gets a say when the rules allow a choice."""
|
||||||
target_name = target["name"]
|
target_name = target["name"]
|
||||||
|
|
@ -559,6 +620,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
continue
|
continue
|
||||||
player_at.setdefault((p["x"], p["y"]), []).append(p)
|
player_at.setdefault((p["x"], p["y"]), []).append(p)
|
||||||
|
|
||||||
|
wizard = board.get("wizard")
|
||||||
|
wizard_pos = (wizard["x"], wizard["y"]) if wizard else None
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for y in range(center_y - radius, center_y + radius + 1):
|
for y in range(center_y - radius, center_y + radius + 1):
|
||||||
row_chars = []
|
row_chars = []
|
||||||
|
|
@ -567,6 +631,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
row_chars.append("@")
|
row_chars.append("@")
|
||||||
elif x < 0 or y < 0 or x >= max_x or y >= max_y:
|
elif x < 0 or y < 0 or x >= max_x or y >= max_y:
|
||||||
row_chars.append("#")
|
row_chars.append("#")
|
||||||
|
elif wizard_pos and (x, y) == wizard_pos:
|
||||||
|
row_chars.append("W")
|
||||||
elif (x, y) in obstacle_at:
|
elif (x, y) in obstacle_at:
|
||||||
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
|
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
|
||||||
elif (x, y) in player_at:
|
elif (x, y) in player_at:
|
||||||
|
|
@ -614,7 +680,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
|
||||||
map_section = f"""
|
map_section = f"""
|
||||||
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
|
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
|
||||||
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
|
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
|
||||||
M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
|
W = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
|
||||||
{chr(10).join(local_map)}
|
{chr(10).join(local_map)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -692,6 +758,7 @@ def main():
|
||||||
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
||||||
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
||||||
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
||||||
|
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
|
||||||
env_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
env_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
||||||
env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION
|
env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION
|
||||||
env_model = os.environ.get("VERTEX_MODEL", DEFAULT_MODEL)
|
env_model = os.environ.get("VERTEX_MODEL", DEFAULT_MODEL)
|
||||||
|
|
@ -710,6 +777,8 @@ def main():
|
||||||
help="Hex color code for the bot avatar (env: BOT_COLOR)")
|
help="Hex color code for the bot avatar (env: BOT_COLOR)")
|
||||||
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
|
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
|
||||||
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
|
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
|
||||||
|
parser.add_argument("-H", "--health", dest="health", type=int, default=env_health,
|
||||||
|
help="Starting health points (default 10) (env: BOT_HEALTH)")
|
||||||
parser.add_argument("-p", "--project", dest="project_id", default=env_project,
|
parser.add_argument("-p", "--project", dest="project_id", default=env_project,
|
||||||
help="Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT)")
|
help="Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT)")
|
||||||
parser.add_argument("-l", "--location", dest="location", default=env_location,
|
parser.add_argument("-l", "--location", dest="location", default=env_location,
|
||||||
|
|
@ -734,6 +803,7 @@ def main():
|
||||||
name=args.name,
|
name=args.name,
|
||||||
color=args.color,
|
color=args.color,
|
||||||
strength=args.strength,
|
strength=args.strength,
|
||||||
|
health=args.health,
|
||||||
server_url=args.server_url,
|
server_url=args.server_url,
|
||||||
project_id=args.project_id,
|
project_id=args.project_id,
|
||||||
location=args.location,
|
location=args.location,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { PlayerList } from './components/PlayerList';
|
||||||
import { RegisterModal } from './components/RegisterModal';
|
import { RegisterModal } from './components/RegisterModal';
|
||||||
import { PartyModal } from './components/PartyModal';
|
import { PartyModal } from './components/PartyModal';
|
||||||
import { BattleModal } from './components/BattleModal';
|
import { BattleModal } from './components/BattleModal';
|
||||||
|
import { WizardChallengeModal } from './components/WizardChallengeModal';
|
||||||
import { ScoreboardModal } from './components/ScoreboardModal';
|
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||||
|
|
||||||
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
|
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
|
||||||
|
|
@ -32,6 +33,8 @@ export function App() {
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
activeBattle,
|
activeBattle,
|
||||||
setActiveBattle,
|
setActiveBattle,
|
||||||
|
activeWizardChallenge,
|
||||||
|
setActiveWizardChallenge,
|
||||||
showScoreboard,
|
showScoreboard,
|
||||||
setShowScoreboard,
|
setShowScoreboard,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
|
|
@ -41,6 +44,7 @@ export function App() {
|
||||||
fightBattle,
|
fightBattle,
|
||||||
movePlayer,
|
movePlayer,
|
||||||
passTurn,
|
passTurn,
|
||||||
|
challengeWizard,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
startGame,
|
startGame,
|
||||||
resetBoard,
|
resetBoard,
|
||||||
|
|
@ -54,6 +58,10 @@ export function App() {
|
||||||
setActiveBattle(null);
|
setActiveBattle(null);
|
||||||
}, [setActiveBattle]);
|
}, [setActiveBattle]);
|
||||||
|
|
||||||
|
const handleCloseWizardChallenge = useCallback(() => {
|
||||||
|
setActiveWizardChallenge(null);
|
||||||
|
}, [setActiveWizardChallenge]);
|
||||||
|
|
||||||
const showNotification = (msg: string) => {
|
const showNotification = (msg: string) => {
|
||||||
setNotification(msg);
|
setNotification(msg);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -159,6 +167,9 @@ export function App() {
|
||||||
selectedPlayer={selectedPlayer}
|
selectedPlayer={selectedPlayer}
|
||||||
availableMoves={availableMoves}
|
availableMoves={availableMoves}
|
||||||
onSelectPlayer={setSelectedPlayer}
|
onSelectPlayer={setSelectedPlayer}
|
||||||
|
onChallengeWizard={async (id) => {
|
||||||
|
await challengeWizard(id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 8-Directional Movement D-Pad & Simulation Controls */}
|
{/* 8-Directional Movement D-Pad & Simulation Controls */}
|
||||||
|
|
@ -172,6 +183,9 @@ export function App() {
|
||||||
onPass={async (id) => {
|
onPass={async (id) => {
|
||||||
await passTurn(id);
|
await passTurn(id);
|
||||||
}}
|
}}
|
||||||
|
onChallengeWizard={async (id) => {
|
||||||
|
await challengeWizard(id);
|
||||||
|
}}
|
||||||
onStepBot={stepActiveBotTurn}
|
onStepBot={stepActiveBotTurn}
|
||||||
isAutoPlaying={isAutoPlaying}
|
isAutoPlaying={isAutoPlaying}
|
||||||
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
||||||
|
|
@ -186,6 +200,9 @@ export function App() {
|
||||||
onOpenPartyModal={() => setIsPartyModalOpen(true)}
|
onOpenPartyModal={() => setIsPartyModalOpen(true)}
|
||||||
onDefeatParty={handleDefeatParty}
|
onDefeatParty={handleDefeatParty}
|
||||||
onFightBattle={handleFightBattle}
|
onFightBattle={handleFightBattle}
|
||||||
|
onChallengeWizard={async (id) => {
|
||||||
|
await challengeWizard(id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Live Event Feed Notification */}
|
{/* Live Event Feed Notification */}
|
||||||
|
|
@ -201,9 +218,9 @@ export function App() {
|
||||||
<RegisterModal
|
<RegisterModal
|
||||||
isOpen={isRegisterOpen}
|
isOpen={isRegisterOpen}
|
||||||
onClose={() => setIsRegisterOpen(false)}
|
onClose={() => setIsRegisterOpen(false)}
|
||||||
onRegister={async (name, color, pieceType) => {
|
onRegister={async (name, color, pieceType, health) => {
|
||||||
const player = await registerPlayer(name, color, 1, pieceType);
|
const player = await registerPlayer(name, color, 1, pieceType, health);
|
||||||
showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) at (${player.x}, ${player.y})!`);
|
showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -224,6 +241,12 @@ export function App() {
|
||||||
onClose={handleCloseBattle}
|
onClose={handleCloseBattle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 3-Bout D20 Wizard Challenge Modal */}
|
||||||
|
<WizardChallengeModal
|
||||||
|
challenge={activeWizardChallenge}
|
||||||
|
onClose={handleCloseWizardChallenge}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Game Conclusion Scoreboard Modal */}
|
{/* Game Conclusion Scoreboard Modal */}
|
||||||
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (
|
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (
|
||||||
<ScoreboardModal
|
<ScoreboardModal
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,28 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{battle.health_losses && Object.keys(battle.health_losses).length > 0 && (
|
||||||
|
<div className="text-slate-300 leading-relaxed">
|
||||||
|
• Defeated squad members suffered <span className="text-rose-400 font-bold">1-3 HP damage</span>:
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(battle.health_losses).map(([pid, loss]) => {
|
||||||
|
const isLeader = pid === battle.killed_leader_id;
|
||||||
|
const label = isLeader ? `${battle.killed_leader_name} (Leader)` : `Bot ${pid.slice(-4)}`;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={pid}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-rose-950/70 border border-rose-800/80 text-rose-300 font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<span>❤️</span>
|
||||||
|
<span>{label}:</span>
|
||||||
|
<strong className="text-rose-200">-{loss} HP</strong>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-slate-400 text-[11px] pt-1">
|
<div className="text-slate-400 text-[11px] pt-1">
|
||||||
New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> • Total Strength:{' '}
|
New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> • Total Strength:{' '}
|
||||||
<strong className="text-amber-400">{battle.new_party_strength}</strong>
|
<strong className="text-amber-400">{battle.new_party_strength}</strong>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import type { AvailableMovesResponse, BoardState, Player } from '../types';
|
import type { AvailableMovesResponse, BoardState, Player } from '../types';
|
||||||
import { drawPlayerPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars';
|
import { drawPlayerPiece, drawWizardPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars';
|
||||||
|
|
||||||
interface BoardCanvasProps {
|
interface BoardCanvasProps {
|
||||||
boardState: BoardState;
|
boardState: BoardState;
|
||||||
|
|
@ -8,6 +8,7 @@ interface BoardCanvasProps {
|
||||||
availableMoves?: AvailableMovesResponse | null;
|
availableMoves?: AvailableMovesResponse | null;
|
||||||
onSelectPlayer: (player: Player | null) => void;
|
onSelectPlayer: (player: Player | null) => void;
|
||||||
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
|
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
|
||||||
|
onChallengeWizard?: (playerId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
|
|
@ -16,6 +17,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
availableMoves,
|
availableMoves,
|
||||||
onSelectPlayer,
|
onSelectPlayer,
|
||||||
onHoverCoord,
|
onHoverCoord,
|
||||||
|
onChallengeWizard,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -422,6 +424,13 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw Wandering Wizard NPC
|
||||||
|
if (boardState.wizard) {
|
||||||
|
const wx = startX + (boardState.wizard.x - min_x) * cellSize;
|
||||||
|
const wy = startY + (boardState.wizard.y - min_y) * cellSize;
|
||||||
|
drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
|
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
|
||||||
boardState.players.forEach((player) => {
|
boardState.players.forEach((player) => {
|
||||||
const px = startX + (player.x - min_x) * cellSize;
|
const px = startX + (player.x - min_x) * cellSize;
|
||||||
|
|
@ -548,6 +557,37 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y)
|
? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isHoveredWizard = Boolean(
|
||||||
|
hoveredCoord &&
|
||||||
|
boardState.wizard &&
|
||||||
|
boardState.wizard.x === hoveredCoord.x &&
|
||||||
|
boardState.wizard.y === hoveredCoord.y
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveSelectedPlayer = selectedPlayer
|
||||||
|
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isSelectedAdjacentToWizard = Boolean(
|
||||||
|
liveSelectedPlayer &&
|
||||||
|
boardState.wizard &&
|
||||||
|
Math.max(
|
||||||
|
Math.abs(liveSelectedPlayer.x - boardState.wizard.x),
|
||||||
|
Math.abs(liveSelectedPlayer.y - boardState.wizard.y)
|
||||||
|
) <= 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelectedTurn = Boolean(
|
||||||
|
boardState.turn.game_started &&
|
||||||
|
liveSelectedPlayer &&
|
||||||
|
currentTurnId === liveSelectedPlayer.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSelectedChallenge = Boolean(
|
||||||
|
liveSelectedPlayer &&
|
||||||
|
(!liveSelectedPlayer.party_id || liveSelectedPlayer.is_party_leader)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -578,6 +618,15 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
|
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
|
{isHoveredWizard && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-purple-300 font-bold flex items-center gap-1">
|
||||||
|
<span>🧙♂️</span>
|
||||||
|
<span>Grand Wizard NPC (Str: {boardState.wizard?.strength})</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{hoveredObstacle && (
|
{hoveredObstacle && (
|
||||||
<>
|
<>
|
||||||
<span className="text-slate-500">|</span>
|
<span className="text-slate-500">|</span>
|
||||||
|
|
@ -638,33 +687,73 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Player Overlay card */}
|
{/* Selected Player Overlay card */}
|
||||||
{selectedPlayer && (
|
{liveSelectedPlayer && (
|
||||||
<div className="absolute bottom-4 left-4 bg-slate-900/90 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-xl flex items-center gap-3 text-xs max-w-sm">
|
<div className="absolute bottom-4 left-4 bg-slate-900/95 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-2xl flex items-center gap-3 text-xs max-w-md">
|
||||||
<div className="w-10 h-10 rounded-xl bg-slate-950/80 flex items-center justify-center shadow-md border border-slate-700/70 p-0.5">
|
<div className="w-10 h-10 rounded-xl bg-slate-950/80 flex items-center justify-center shadow-md border border-slate-700/70 p-0.5">
|
||||||
<PixelAvatar
|
<PixelAvatar
|
||||||
pieceType={getPlayerPieceType(selectedPlayer)}
|
pieceType={getPlayerPieceType(liveSelectedPlayer)}
|
||||||
color={selectedPlayer.color}
|
color={liveSelectedPlayer.color}
|
||||||
isLeader={selectedPlayer.is_party_leader}
|
isLeader={liveSelectedPlayer.is_party_leader}
|
||||||
size={36}
|
size={36}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
|
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
|
||||||
{selectedPlayer.name}
|
{liveSelectedPlayer.name}
|
||||||
{selectedPlayer.is_party_leader && (
|
{liveSelectedPlayer.is_party_leader && (
|
||||||
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
|
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
|
||||||
)}
|
)}
|
||||||
{selectedPlayer.id === currentTurnId && (
|
{liveSelectedPlayer.id === currentTurnId && (
|
||||||
<span className="text-[10px] text-emerald-400 font-mono">• Turn</span>
|
<span className="text-[10px] text-emerald-400 font-mono">• Turn</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-400 font-mono text-[11px]">
|
<div className="text-slate-400 font-mono text-[11px]">
|
||||||
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) • Score:{' '}
|
Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) • Score:{' '}
|
||||||
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
<span className={liveSelectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||||
{selectedPlayer.score}
|
{liveSelectedPlayer.score}
|
||||||
</span>
|
</span>
|
||||||
|
{' '}• HP: <span className="text-rose-400 font-bold">❤️{liveSelectedPlayer.health ?? 10}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Challenge Wizard button on Selected Player Card */}
|
||||||
|
{isSelectedAdjacentToWizard && onChallengeWizard && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!boardState.turn.game_started) {
|
||||||
|
alert("Game has not started yet. Click 'Start Game' in the header first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSelectedTurn) {
|
||||||
|
alert(`It is not ${liveSelectedPlayer.name}'s turn! Wait for their turn to challenge the wizard.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canSelectedChallenge) {
|
||||||
|
alert("Only party leader can challenge the wizard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChallengeWizard(liveSelectedPlayer.id);
|
||||||
|
}}
|
||||||
|
disabled={!isSelectedTurn || !canSelectedChallenge}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold flex items-center gap-1.5 transition-all ${
|
||||||
|
isSelectedTurn && canSelectedChallenge
|
||||||
|
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
|
||||||
|
: 'bg-purple-950/50 text-purple-400/60 border border-purple-800/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
!isSelectedTurn
|
||||||
|
? `Wait for ${liveSelectedPlayer.name}'s turn`
|
||||||
|
: !canSelectedChallenge
|
||||||
|
? 'Only party leader can challenge'
|
||||||
|
: 'Challenge Wizard to a 3-bout D20 duel!'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>🧙♂️</span>
|
||||||
|
<span>{isSelectedTurn && canSelectedChallenge ? 'Challenge Wizard' : 'Duel (Wait Turn)'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ interface MovementControlsProps {
|
||||||
availableMoves: AvailableMovesResponse | null;
|
availableMoves: AvailableMovesResponse | null;
|
||||||
onMove: (playerId: string, direction: string) => Promise<unknown>;
|
onMove: (playerId: string, direction: string) => Promise<unknown>;
|
||||||
onPass: (playerId: string) => Promise<unknown>;
|
onPass: (playerId: string) => Promise<unknown>;
|
||||||
|
onChallengeWizard?: (playerId: string) => Promise<unknown>;
|
||||||
onStepBot: () => void;
|
onStepBot: () => void;
|
||||||
isAutoPlaying: boolean;
|
isAutoPlaying: boolean;
|
||||||
onToggleAutoPlay: () => void;
|
onToggleAutoPlay: () => void;
|
||||||
|
|
@ -18,16 +19,54 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
availableMoves,
|
availableMoves,
|
||||||
onMove,
|
onMove,
|
||||||
onPass,
|
onPass,
|
||||||
|
onChallengeWizard,
|
||||||
onStepBot,
|
onStepBot,
|
||||||
isAutoPlaying,
|
isAutoPlaying,
|
||||||
onToggleAutoPlay,
|
onToggleAutoPlay,
|
||||||
}) => {
|
}) => {
|
||||||
const isStarted = Boolean(boardState.turn?.game_started);
|
const isStarted = Boolean(boardState.turn?.game_started);
|
||||||
const currentTurnId = boardState.turn.current_player_id;
|
const currentTurnId = boardState.turn.current_player_id;
|
||||||
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
|
const activePlayer = boardState.players.find((p) => p.id === currentTurnId) || null;
|
||||||
const controlledPlayer = selectedPlayer || activePlayer;
|
// Always resolve the live coordinates from boardState.players to prevent stale player coordinates
|
||||||
|
const liveSelectedPlayer = selectedPlayer
|
||||||
|
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
|
||||||
|
: null;
|
||||||
|
const controlledPlayer = liveSelectedPlayer || activePlayer;
|
||||||
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
||||||
|
|
||||||
|
const isAdjacentToWizard = Boolean(
|
||||||
|
controlledPlayer &&
|
||||||
|
boardState.wizard &&
|
||||||
|
Math.max(
|
||||||
|
Math.abs(controlledPlayer.x - boardState.wizard.x),
|
||||||
|
Math.abs(controlledPlayer.y - boardState.wizard.y)
|
||||||
|
) <= 1
|
||||||
|
);
|
||||||
|
const canInitiateWizardChallenge = !controlledPlayer?.party_id || controlledPlayer?.is_party_leader;
|
||||||
|
|
||||||
|
const handleChallengeWizardClick = useCallback(() => {
|
||||||
|
if (!controlledPlayer) return;
|
||||||
|
if (!isStarted) {
|
||||||
|
alert("Game has not started yet. Click 'Start Game' in header first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMyTurn) {
|
||||||
|
alert(`It is not ${controlledPlayer.name}'s turn! Current turn belongs to ${activePlayer?.name || 'another bot'}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canInitiateWizardChallenge) {
|
||||||
|
alert("Party member cannot challenge the wizard individually. Only party leader can initiate challenges.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAdjacentToWizard) {
|
||||||
|
alert("Must be within 1 space of the wizard to challenge.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChallengeWizard?.(controlledPlayer.id).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) alert(err.message);
|
||||||
|
});
|
||||||
|
}, [controlledPlayer, isStarted, isMyTurn, canInitiateWizardChallenge, isAdjacentToWizard, activePlayer, onChallengeWizard]);
|
||||||
|
|
||||||
const handleDirectionClick = useCallback(
|
const handleDirectionClick = useCallback(
|
||||||
(dir: string) => {
|
(dir: string) => {
|
||||||
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
||||||
|
|
@ -233,6 +272,39 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Challenge Wizard Button if adjacent */}
|
||||||
|
{isAdjacentToWizard && onChallengeWizard && (
|
||||||
|
<button
|
||||||
|
onClick={handleChallengeWizardClick}
|
||||||
|
disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge}
|
||||||
|
className={`w-full py-2.5 px-3 rounded-xl font-bold font-mono text-xs flex items-center justify-center gap-2 border transition-all ${
|
||||||
|
!isStarted || !isMyTurn || !canInitiateWizardChallenge
|
||||||
|
? 'bg-purple-950/40 text-purple-400/60 border-purple-900/50 cursor-not-allowed'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-500 text-white border-purple-400 shadow-xl shadow-purple-950/70 animate-pulse active:scale-95 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
!isStarted
|
||||||
|
? 'Game has not started yet'
|
||||||
|
: !isMyTurn
|
||||||
|
? `Waiting for ${controlledPlayer?.name}'s turn`
|
||||||
|
: !canInitiateWizardChallenge
|
||||||
|
? 'Only party leader can challenge the wizard'
|
||||||
|
: 'Challenge the Wizard NPC to a 3-bout D20 duel! (+2 score on win, -2 health/pts on loss)'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-base">🧙♂️</span>
|
||||||
|
<span>
|
||||||
|
{!isStarted
|
||||||
|
? 'Start Game to Duel Wizard'
|
||||||
|
: !isMyTurn
|
||||||
|
? `Wait for Turn to Duel`
|
||||||
|
: !canInitiateWizardChallenge
|
||||||
|
? 'Leader Only'
|
||||||
|
: 'Challenge Wizard (D20 Duel)'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Simulation / Bot Controls */}
|
{/* Simulation / Bot Controls */}
|
||||||
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
|
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface PlayerListProps {
|
||||||
onOpenPartyModal?: () => void;
|
onOpenPartyModal?: () => void;
|
||||||
onDefeatParty?: (partyId: string) => void;
|
onDefeatParty?: (partyId: string) => void;
|
||||||
onFightBattle?: (challengerId: string, defenderId: string) => void;
|
onFightBattle?: (challengerId: string, defenderId: string) => void;
|
||||||
|
onChallengeWizard?: (playerId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerList: React.FC<PlayerListProps> = ({
|
export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
|
|
@ -19,6 +20,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
onRemovePlayer,
|
onRemovePlayer,
|
||||||
onOpenPartyModal,
|
onOpenPartyModal,
|
||||||
onDefeatParty,
|
onDefeatParty,
|
||||||
|
onChallengeWizard,
|
||||||
}) => {
|
}) => {
|
||||||
const { players, parties = [], turn } = boardState;
|
const { players, parties = [], turn } = boardState;
|
||||||
const currentTurnId = turn.current_player_id;
|
const currentTurnId = turn.current_player_id;
|
||||||
|
|
@ -28,6 +30,27 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
|
p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isStarted = Boolean(turn.game_started);
|
||||||
|
const liveSelected = selectedPlayer
|
||||||
|
? players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
|
||||||
|
: null;
|
||||||
|
const activePlayer = players.find((p) => p.id === currentTurnId) || null;
|
||||||
|
const candidate = liveSelected || activePlayer;
|
||||||
|
const candidateAdjacent = Boolean(
|
||||||
|
candidate &&
|
||||||
|
boardState.wizard &&
|
||||||
|
Math.max(
|
||||||
|
Math.abs(candidate.x - boardState.wizard.x),
|
||||||
|
Math.abs(candidate.y - boardState.wizard.y)
|
||||||
|
) <= 1
|
||||||
|
);
|
||||||
|
const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId);
|
||||||
|
const candidateCanChallenge = Boolean(
|
||||||
|
candidateAdjacent &&
|
||||||
|
candidateIsTurn &&
|
||||||
|
(!candidate?.party_id || candidate?.is_party_leader)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
|
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -89,6 +112,68 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Wizard NPC Section */}
|
||||||
|
{boardState.wizard && (
|
||||||
|
<div className="p-3 border-b border-slate-800 bg-purple-950/20">
|
||||||
|
<div className="text-[11px] font-mono text-purple-300 uppercase tracking-wider mb-1.5 flex items-center justify-between">
|
||||||
|
<span>🧙 Wandering Wizard (NPC)</span>
|
||||||
|
<span className="text-[9px] bg-purple-900/60 px-1.5 py-0.5 rounded text-purple-300 border border-purple-700 font-mono">
|
||||||
|
CHALLENGE NPC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/90 border border-purple-500/40 rounded-lg p-2 flex items-center justify-between text-xs">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-purple-300 flex items-center gap-1.5">
|
||||||
|
<span>🧙♂️</span> {boardState.wizard.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono mt-0.5">
|
||||||
|
pos: <span className="text-emerald-400">({boardState.wizard.x}, {boardState.wizard.y})</span> • Str: <span className="text-amber-400 font-bold">⚡{boardState.wizard.strength}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{candidateAdjacent && onChallengeWizard ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!candidate) return;
|
||||||
|
if (!isStarted) {
|
||||||
|
alert("Game has not started yet. Click 'Start Game' in header first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!candidateIsTurn) {
|
||||||
|
alert(`It is not ${candidate.name}'s turn! Wait for their turn to challenge.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidate.party_id && !candidate.is_party_leader) {
|
||||||
|
alert("Only party leader can challenge the wizard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChallengeWizard(candidate.id);
|
||||||
|
}}
|
||||||
|
disabled={!candidateCanChallenge}
|
||||||
|
className={`ml-2 px-2.5 py-1 rounded text-xs font-mono font-bold flex items-center gap-1 transition-all ${
|
||||||
|
candidateCanChallenge
|
||||||
|
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
|
||||||
|
: 'bg-purple-950/60 text-purple-400/60 border border-purple-800/40 cursor-not-allowed text-[10px]'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
!candidateIsTurn
|
||||||
|
? `Wait for ${candidate?.name || 'bot'}'s turn`
|
||||||
|
: candidate?.party_id && !candidate.is_party_leader
|
||||||
|
? 'Only party leader can challenge'
|
||||||
|
: `Challenge Wizard with ${candidate?.name}!`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>⚔️</span>
|
||||||
|
<span>{candidateCanChallenge ? 'Challenge' : 'Wait Turn'}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] font-mono text-purple-400 bg-purple-950/80 border border-purple-800 px-2 py-1 rounded text-center">
|
||||||
|
D20 Duel
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action to form party */}
|
{/* Action to form party */}
|
||||||
{players.length >= 2 && onOpenPartyModal && (
|
{players.length >= 2 && onOpenPartyModal && (
|
||||||
<div className="p-2 border-b border-slate-800 bg-slate-950/30">
|
<div className="p-2 border-b border-slate-800 bg-slate-950/30">
|
||||||
|
|
@ -178,7 +263,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] font-mono text-slate-400">
|
<div className="text-[11px] font-mono text-slate-400">
|
||||||
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> • Str: <span className="text-amber-400">⚡{player.strength || 1}</span>
|
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> • Str: <span className="text-amber-400">⚡{player.strength || 1}</span> • HP: <span className="text-rose-400 font-bold">❤️{player.health ?? 10}</span>
|
||||||
{partyName && (
|
{partyName && (
|
||||||
<span className="ml-1 text-sky-400 truncate font-sans">
|
<span className="ml-1 text-sky-400 truncate font-sans">
|
||||||
• {isLeader ? 'Leader' : 'Squad'}
|
• {isLeader ? 'Leader' : 'Squad'}
|
||||||
|
|
@ -188,7 +273,52 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 opacity-60 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||||
|
{boardState.wizard &&
|
||||||
|
Math.max(
|
||||||
|
Math.abs(player.x - boardState.wizard.x),
|
||||||
|
Math.abs(player.y - boardState.wizard.y)
|
||||||
|
) <= 1 &&
|
||||||
|
onChallengeWizard && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isStarted) {
|
||||||
|
alert("Game has not started yet. Click 'Start Game' in header first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTurnId !== player.id) {
|
||||||
|
alert(`It is not ${player.name}'s turn! Wait for their turn to challenge.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.party_id && !player.is_party_leader) {
|
||||||
|
alert("Only party leader can challenge the wizard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChallengeWizard(player.id);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!isStarted ||
|
||||||
|
currentTurnId !== player.id ||
|
||||||
|
Boolean(player.party_id && !player.is_party_leader)
|
||||||
|
}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-mono font-bold flex items-center gap-1 transition-all ${
|
||||||
|
isStarted &&
|
||||||
|
currentTurnId === player.id &&
|
||||||
|
(!player.party_id || player.is_party_leader)
|
||||||
|
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
|
||||||
|
: 'bg-purple-950/50 text-purple-400/50 border border-purple-800/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
currentTurnId === player.id
|
||||||
|
? `Challenge Wizard with ${player.name}!`
|
||||||
|
: `Wait for ${player.name}'s turn to challenge`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>🧙</span>
|
||||||
|
<span>Duel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const RANDOM_NAMES = [
|
||||||
interface RegisterModalProps {
|
interface RegisterModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRegister: (name: string, color: string, pieceType?: PieceType) => Promise<unknown>;
|
onRegister: (name: string, color: string, pieceType?: PieceType, health?: number) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RegisterModal: React.FC<RegisterModalProps> = ({
|
export const RegisterModal: React.FC<RegisterModalProps> = ({
|
||||||
|
|
@ -43,6 +43,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [color, setColor] = useState('#38BDF8');
|
const [color, setColor] = useState('#38BDF8');
|
||||||
const [pieceType, setPieceType] = useState<PieceType>('knight');
|
const [pieceType, setPieceType] = useState<PieceType>('knight');
|
||||||
|
const [health, setHealth] = useState<number>(10);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -74,9 +75,10 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
await onRegister(name.trim(), color, pieceType);
|
await onRegister(name.trim(), color, pieceType, health);
|
||||||
onClose();
|
onClose();
|
||||||
setName('');
|
setName('');
|
||||||
|
setHealth(10);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -214,6 +216,36 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Starting Health */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Starting Health (HP)
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] font-mono text-rose-400 font-semibold">
|
||||||
|
❤️ {health} HP {health === 10 ? '(Default)' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
value={health}
|
||||||
|
onChange={(e) => setHealth(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-rose-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={health}
|
||||||
|
onChange={(e) => setHealth(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="w-16 bg-slate-950 border border-slate-700 focus:border-rose-500 rounded-lg px-2.5 py-1.5 text-xs font-mono text-rose-300 text-center focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Color & Faction Chooser */}
|
{/* Color & Faction Chooser */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { WizardChallengeResult } from '../types';
|
||||||
|
|
||||||
|
interface WizardChallengeModalProps {
|
||||||
|
challenge: WizardChallengeResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WizardChallengeModal: React.FC<WizardChallengeModalProps> = ({
|
||||||
|
challenge,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [countdown, setCountdown] = useState(5);
|
||||||
|
const [progressPercent, setProgressPercent] = useState(100);
|
||||||
|
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeKey = challenge
|
||||||
|
? `${challenge.challenger_id}_${challenge.player_bouts_won}_${challenge.wizard_bouts_won}_${challenge.score_change}_${challenge.health_change}_${challenge.wizard_respawn_position?.x}_${challenge.wizard_respawn_position?.y}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!challengeKey) {
|
||||||
|
setCountdown(5);
|
||||||
|
setProgressPercent(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_MS = 5000;
|
||||||
|
const targetEndTime = Date.now() + DURATION_MS;
|
||||||
|
setCountdown(5);
|
||||||
|
setProgressPercent(100);
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const remainingMs = targetEndTime - now;
|
||||||
|
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
setCountdown(0);
|
||||||
|
setProgressPercent(0);
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.ceil(remainingMs / 1000);
|
||||||
|
const percent = Math.min(100, Math.max(0, (remainingMs / DURATION_MS) * 100));
|
||||||
|
setCountdown(seconds);
|
||||||
|
setProgressPercent(percent);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [challengeKey]);
|
||||||
|
|
||||||
|
if (!challenge) return null;
|
||||||
|
|
||||||
|
const playerName = challenge.challenger_name;
|
||||||
|
const wizardName = challenge.wizard_name || 'Grand Wizard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-slate-900 border-2 border-purple-500/80 rounded-2xl w-full max-w-xl shadow-2xl p-6 relative overflow-hidden">
|
||||||
|
{/* Glowing cyber wizard header banner */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-purple-500 via-fuchsia-500 to-indigo-500" />
|
||||||
|
|
||||||
|
{/* 5-second automatic progress bar */}
|
||||||
|
<div className="absolute top-2 left-0 right-0 h-1 bg-slate-800">
|
||||||
|
<div
|
||||||
|
className="h-full bg-purple-400 transition-all duration-100 ease-linear"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-800 mt-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">🧙♂️</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-slate-100 font-mono tracking-wide flex items-center gap-2">
|
||||||
|
<span>WIZARD NPC DUEL: 3-BOUT D20 CHALLENGE</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
<span className="text-sky-300 font-semibold">{playerName}</span> vs{' '}
|
||||||
|
<span className="text-purple-300 font-semibold">{wizardName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onCloseRef.current()}
|
||||||
|
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
||||||
|
title="Dismiss now"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 Bouts Breakdown */}
|
||||||
|
<div className="space-y-3 mb-5">
|
||||||
|
{challenge.bouts.map((bout) => (
|
||||||
|
<div
|
||||||
|
key={bout.bout_number}
|
||||||
|
className={`p-3 rounded-xl border flex items-center justify-between transition-colors ${
|
||||||
|
bout.winner === 'player'
|
||||||
|
? 'bg-sky-950/30 border-sky-500/50'
|
||||||
|
: bout.winner === 'wizard'
|
||||||
|
? 'bg-purple-950/30 border-purple-500/50'
|
||||||
|
: 'bg-slate-800/40 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Bout Index */}
|
||||||
|
<div className="w-16 font-mono text-xs font-bold text-slate-400">
|
||||||
|
Bout #{bout.bout_number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Bout Score */}
|
||||||
|
<div className="flex-1 text-left px-2">
|
||||||
|
<div className="text-xs font-bold text-slate-200 truncate">
|
||||||
|
{playerName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-mono text-slate-400 flex items-center gap-1">
|
||||||
|
<span>🎲 D20({bout.player_roll})</span>
|
||||||
|
<span>×</span>
|
||||||
|
<span>⚡Str({bout.player_strength})</span>
|
||||||
|
<span>=</span>
|
||||||
|
<strong className="text-sky-400 text-xs">{bout.player_score}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VS Divider */}
|
||||||
|
<div className="px-2 text-slate-600 font-mono text-xs font-black">VS</div>
|
||||||
|
|
||||||
|
{/* Wizard Bout Score */}
|
||||||
|
<div className="flex-1 text-right px-2">
|
||||||
|
<div className="text-xs font-bold text-purple-300 truncate">
|
||||||
|
{wizardName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-mono text-slate-400 flex items-center justify-end gap-1">
|
||||||
|
<strong className="text-purple-400 text-xs">{bout.wizard_score}</strong>
|
||||||
|
<span>=</span>
|
||||||
|
<span>⚡Str({bout.wizard_strength})</span>
|
||||||
|
<span>×</span>
|
||||||
|
<span>🎲 D20({bout.wizard_roll})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bout Outcome Badge */}
|
||||||
|
<div className="w-20 text-right">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-mono px-2 py-0.5 rounded font-bold uppercase ${
|
||||||
|
bout.winner === 'player'
|
||||||
|
? 'bg-sky-900/80 text-sky-200 border border-sky-600'
|
||||||
|
: bout.winner === 'wizard'
|
||||||
|
? 'bg-purple-900/80 text-purple-200 border border-purple-600'
|
||||||
|
: 'bg-slate-700 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bout.winner === 'player'
|
||||||
|
? 'Player'
|
||||||
|
: bout.winner === 'wizard'
|
||||||
|
? 'Wizard'
|
||||||
|
: 'Tie'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Match Outcome Banner */}
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl border mb-4 text-center ${
|
||||||
|
challenge.player_won
|
||||||
|
? 'bg-emerald-950/40 border-emerald-500/60 text-emerald-200'
|
||||||
|
: 'bg-rose-950/40 border-rose-500/60 text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-mono uppercase tracking-wider mb-1">
|
||||||
|
Duel Outcome: {challenge.player_bouts_won} - {challenge.wizard_bouts_won}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-extrabold flex items-center justify-center gap-2">
|
||||||
|
<span>{challenge.player_won ? '✨ CHALLENGE VICTORY ✨' : '💀 CHALLENGE DEFEAT 💀'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs font-mono space-y-1">
|
||||||
|
{challenge.player_won ? (
|
||||||
|
<div className="text-emerald-300 font-bold">
|
||||||
|
🎉 +{challenge.score_change} Score Points awarded! (Total: {challenge.new_score})
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-rose-300 font-bold">
|
||||||
|
{challenge.health_change < 0 ? (
|
||||||
|
<span>
|
||||||
|
💔 Lost {Math.abs(challenge.health_change)} Health! (Remaining HP: {challenge.new_health})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
📉 Out of HP! Lost {Math.abs(challenge.score_change)} Score points! (Score: {challenge.new_score})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-purple-300/80 text-[11px] pt-1">
|
||||||
|
🔮 The Wizard vanished in a puff of smoke and teleported to ({challenge.wizard_respawn_position?.x}, {challenge.wizard_respawn_position?.y}).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Controls / Auto-close countdown */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 font-mono">
|
||||||
|
<span>Auto-closing in {countdown}s...</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onCloseRef.current()}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-slate-800 hover:bg-purple-700 text-slate-200 hover:text-white border border-slate-700 transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
Acknowledge & Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
PartyDefeatResult,
|
PartyDefeatResult,
|
||||||
Player,
|
Player,
|
||||||
TurnInfo,
|
TurnInfo,
|
||||||
|
WizardChallengeResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const INITIAL_BOARD: BoardState = {
|
const INITIAL_BOARD: BoardState = {
|
||||||
|
|
@ -25,6 +26,7 @@ const INITIAL_BOARD: BoardState = {
|
||||||
players: [],
|
players: [],
|
||||||
parties: [],
|
parties: [],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
wizard: null,
|
||||||
turn: {
|
turn: {
|
||||||
game_started: false,
|
game_started: false,
|
||||||
current_player_id: null,
|
current_player_id: null,
|
||||||
|
|
@ -45,6 +47,7 @@ export function useGameSocket() {
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
||||||
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
||||||
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
||||||
|
const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null);
|
||||||
const [showScoreboard, setShowScoreboard] = useState(false);
|
const [showScoreboard, setShowScoreboard] = useState(false);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -52,6 +55,8 @@ export function useGameSocket() {
|
||||||
const autoPlayIntervalRef = useRef<number | null>(null);
|
const autoPlayIntervalRef = useRef<number | null>(null);
|
||||||
const activeBattleRef = useRef<BattleResult | null>(null);
|
const activeBattleRef = useRef<BattleResult | null>(null);
|
||||||
activeBattleRef.current = activeBattle;
|
activeBattleRef.current = activeBattle;
|
||||||
|
const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null);
|
||||||
|
activeWizardChallengeRef.current = activeWizardChallenge;
|
||||||
|
|
||||||
const fetchBoard = useCallback(async () => {
|
const fetchBoard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,6 +68,10 @@ export function useGameSocket() {
|
||||||
parties: data.parties || prev.parties || [],
|
parties: data.parties || prev.parties || [],
|
||||||
conclusion: data.conclusion || null,
|
conclusion: data.conclusion || null,
|
||||||
}));
|
}));
|
||||||
|
setSelectedPlayer((curr) => {
|
||||||
|
if (!curr) return null;
|
||||||
|
return data.players.find((p) => p.id === curr.id) || null;
|
||||||
|
});
|
||||||
if (data.conclusion && data.conclusion.concluded) {
|
if (data.conclusion && data.conclusion.concluded) {
|
||||||
setShowScoreboard(true);
|
setShowScoreboard(true);
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +121,10 @@ export function useGameSocket() {
|
||||||
parties: data.state.parties || [],
|
parties: data.state.parties || [],
|
||||||
conclusion: data.state.conclusion || null,
|
conclusion: data.state.conclusion || null,
|
||||||
});
|
});
|
||||||
|
setSelectedPlayer((curr) => {
|
||||||
|
if (!curr || !data.state?.players) return null;
|
||||||
|
return data.state.players.find((p: Player) => p.id === curr.id) || null;
|
||||||
|
});
|
||||||
if (data.state.conclusion && data.state.conclusion.concluded) {
|
if (data.state.conclusion && data.state.conclusion.concluded) {
|
||||||
setShowScoreboard(true);
|
setShowScoreboard(true);
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +158,19 @@ export function useGameSocket() {
|
||||||
turn: data.turn ?? prev.turn,
|
turn: data.turn ?? prev.turn,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setSelectedPlayer((curr) => {
|
||||||
|
if (!curr) return null;
|
||||||
|
if (data.affected_players) {
|
||||||
|
const match = data.affected_players.find((p: Player) => p.id === curr.id);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
if (data.player && data.player.id === curr.id) return data.player;
|
||||||
|
if (data.players) {
|
||||||
|
const match = data.players.find((p: Player) => p.id === curr.id);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return curr;
|
||||||
|
});
|
||||||
} else if (data.event === 'party_formed' || data.event === 'party_updated') {
|
} else if (data.event === 'party_formed' || data.event === 'party_updated') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -179,6 +205,21 @@ export function useGameSocket() {
|
||||||
const b: BattleResult = data.battle;
|
const b: BattleResult = data.battle;
|
||||||
setActiveBattle(b);
|
setActiveBattle(b);
|
||||||
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
||||||
|
} else if (data.event === 'wizard_challenge_resolved') {
|
||||||
|
setBoardState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
players: data.players ?? prev.players,
|
||||||
|
wizard: data.wizard ?? prev.wizard,
|
||||||
|
turn: data.turn ?? prev.turn,
|
||||||
|
}));
|
||||||
|
const c: WizardChallengeResult = data.challenge;
|
||||||
|
setActiveWizardChallenge(c);
|
||||||
|
const wizName = c.wizard_name || 'Grand Wizard';
|
||||||
|
setLastEventMessage(
|
||||||
|
c.player_won
|
||||||
|
? `🧙 ${c.challenger_name} defeated ${wizName}! (+${c.score_change} score)`
|
||||||
|
: `🧙 ${c.challenger_name} lost to ${wizName}! (${c.health_change < 0 ? `${c.health_change} HP` : `${c.score_change} pts`})`
|
||||||
|
);
|
||||||
} else if (data.event === 'game_concluded') {
|
} else if (data.event === 'game_concluded') {
|
||||||
const conc: GameConclusion = data.conclusion;
|
const conc: GameConclusion = data.conclusion;
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
|
|
@ -227,6 +268,7 @@ export function useGameSocket() {
|
||||||
setSelectedPlayer(null);
|
setSelectedPlayer(null);
|
||||||
setAvailableMoves(null);
|
setAvailableMoves(null);
|
||||||
setActiveBattle(null);
|
setActiveBattle(null);
|
||||||
|
setActiveWizardChallenge(null);
|
||||||
setShowScoreboard(false);
|
setShowScoreboard(false);
|
||||||
setLastEventMessage('🔄 Board has been reset.');
|
setLastEventMessage('🔄 Board has been reset.');
|
||||||
}
|
}
|
||||||
|
|
@ -273,12 +315,13 @@ export function useGameSocket() {
|
||||||
name: string,
|
name: string,
|
||||||
color: string,
|
color: string,
|
||||||
strength: number = 1,
|
strength: number = 1,
|
||||||
piece_type?: 'knight' | 'warrior'
|
piece_type?: string,
|
||||||
|
health: number = 10
|
||||||
): Promise<Player> => {
|
): Promise<Player> => {
|
||||||
const res = await fetch('/api/players', {
|
const res = await fetch('/api/players', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, color, strength, piece_type }),
|
body: JSON.stringify({ name, color, strength, piece_type, health }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
|
|
@ -353,6 +396,9 @@ export function useGameSocket() {
|
||||||
throw new Error(err.detail || 'Failed to move player');
|
throw new Error(err.detail || 'Failed to move player');
|
||||||
}
|
}
|
||||||
const data: MoveResponse = await res.json();
|
const data: MoveResponse = await res.json();
|
||||||
|
if (data.player) {
|
||||||
|
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
|
||||||
|
}
|
||||||
if (data.battle_triggered && data.battle_result) {
|
if (data.battle_triggered && data.battle_result) {
|
||||||
setActiveBattle(data.battle_result);
|
setActiveBattle(data.battle_result);
|
||||||
}
|
}
|
||||||
|
|
@ -360,6 +406,7 @@ export function useGameSocket() {
|
||||||
setIsAutoPlaying(false);
|
setIsAutoPlaying(false);
|
||||||
setShowScoreboard(true);
|
setShowScoreboard(true);
|
||||||
}
|
}
|
||||||
|
fetchAvailableMoves(playerId);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -397,14 +444,29 @@ export function useGameSocket() {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const challengeWizard = async (playerId: string): Promise<WizardChallengeResult> => {
|
||||||
|
const res = await fetch('/api/wizard/challenge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ player_id: playerId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to challenge wizard');
|
||||||
|
}
|
||||||
|
const data: WizardChallengeResult = await res.json();
|
||||||
|
setActiveWizardChallenge(data);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
// Step active bot turn according to its explicit autonomous goal:
|
// Step active bot turn according to its explicit autonomous goal:
|
||||||
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
||||||
// - Party leader: seeks other parties to find and defeat all other parties
|
// - Party leader: seeks other parties to find and defeat all other parties
|
||||||
const stepActiveBotTurn = useCallback(async () => {
|
const stepActiveBotTurn = useCallback(async () => {
|
||||||
// Cannot step if game hasn't started yet
|
// Cannot step if game hasn't started yet
|
||||||
if (!boardState.turn.game_started) return;
|
if (!boardState.turn.game_started) return;
|
||||||
// If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes
|
// If a battle or wizard challenge modal is currently open, pause turn stepping until modal acknowledges/closes
|
||||||
if (activeBattleRef.current) return;
|
if (activeBattleRef.current || activeWizardChallengeRef.current) return;
|
||||||
|
|
||||||
const currentId = boardState.turn.current_player_id;
|
const currentId = boardState.turn.current_player_id;
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
|
|
@ -420,6 +482,15 @@ export function useGameSocket() {
|
||||||
} else if (data.action_taken === 'battled' && data.battle_result) {
|
} else if (data.action_taken === 'battled' && data.battle_result) {
|
||||||
setActiveBattle(data.battle_result);
|
setActiveBattle(data.battle_result);
|
||||||
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
|
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
|
||||||
|
} else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) {
|
||||||
|
const wcr = data.wizard_challenge_result;
|
||||||
|
setActiveWizardChallenge(wcr);
|
||||||
|
const wizName = wcr.wizard_name || 'Grand Wizard';
|
||||||
|
setLastEventMessage(
|
||||||
|
wcr.player_won
|
||||||
|
? `🧙 ${wcr.challenger_name} defeated ${wizName}! (+${wcr.score_change} score)`
|
||||||
|
: `🧙 ${wcr.challenger_name} lost to ${wizName}! (${wcr.health_change < 0 ? `${wcr.health_change} HP` : `${wcr.score_change} pts`})`
|
||||||
|
);
|
||||||
} else if (data.move_result?.battle_result) {
|
} else if (data.move_result?.battle_result) {
|
||||||
setActiveBattle(data.move_result.battle_result);
|
setActiveBattle(data.move_result.battle_result);
|
||||||
}
|
}
|
||||||
|
|
@ -432,7 +503,7 @@ export function useGameSocket() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Bot AI step error:', err);
|
console.warn('Bot AI step error:', err);
|
||||||
}
|
}
|
||||||
}, [boardState.turn.current_player_id]);
|
}, [boardState.turn.current_player_id, boardState.turn.game_started]);
|
||||||
|
|
||||||
// Autoplay interval loop
|
// Autoplay interval loop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -463,6 +534,8 @@ export function useGameSocket() {
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
activeBattle,
|
activeBattle,
|
||||||
setActiveBattle,
|
setActiveBattle,
|
||||||
|
activeWizardChallenge,
|
||||||
|
setActiveWizardChallenge,
|
||||||
showScoreboard,
|
showScoreboard,
|
||||||
setShowScoreboard,
|
setShowScoreboard,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
|
|
@ -472,6 +545,7 @@ export function useGameSocket() {
|
||||||
fightBattle,
|
fightBattle,
|
||||||
movePlayer,
|
movePlayer,
|
||||||
passTurn,
|
passTurn,
|
||||||
|
challengeWizard,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
startGame,
|
startGame,
|
||||||
resetBoard,
|
resetBoard,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export interface Player {
|
||||||
y: number;
|
y: number;
|
||||||
strength: number;
|
strength: number;
|
||||||
score: number;
|
score: number;
|
||||||
|
health?: number;
|
||||||
|
max_health?: number;
|
||||||
piece_type?: 'knight' | 'warrior';
|
piece_type?: 'knight' | 'warrior';
|
||||||
party_id?: string | null;
|
party_id?: string | null;
|
||||||
is_party_leader: boolean;
|
is_party_leader: boolean;
|
||||||
|
|
@ -57,12 +59,23 @@ export interface GameConclusion {
|
||||||
rankings?: Player[];
|
rankings?: Player[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WizardNPC {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
strength: number;
|
||||||
|
color: string;
|
||||||
|
dialogue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoardState {
|
export interface BoardState {
|
||||||
config: GridConfig;
|
config: GridConfig;
|
||||||
player_count: number;
|
player_count: number;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
obstacles: Obstacle[];
|
obstacles: Obstacle[];
|
||||||
|
wizard?: WizardNPC | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
game_started?: boolean;
|
game_started?: boolean;
|
||||||
conclusion?: GameConclusion | null;
|
conclusion?: GameConclusion | null;
|
||||||
|
|
@ -117,6 +130,16 @@ export interface RadarTarget {
|
||||||
can_battle: boolean;
|
can_battle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WizardRadarTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
distance: number;
|
||||||
|
strength: number;
|
||||||
|
can_challenge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotRadarResponse {
|
export interface BotRadarResponse {
|
||||||
player_id: string;
|
player_id: string;
|
||||||
current_x: number;
|
current_x: number;
|
||||||
|
|
@ -124,6 +147,7 @@ export interface BotRadarResponse {
|
||||||
bot_goal: string;
|
bot_goal: string;
|
||||||
targets: RadarTarget[];
|
targets: RadarTarget[];
|
||||||
nearest_target?: RadarTarget | null;
|
nearest_target?: RadarTarget | null;
|
||||||
|
wizard?: WizardRadarTarget | null;
|
||||||
recommended_direction?: string | null;
|
recommended_direction?: string | null;
|
||||||
recommended_action: string;
|
recommended_action: string;
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +184,34 @@ export interface BattleResult {
|
||||||
absorbed_members: string[];
|
absorbed_members: string[];
|
||||||
new_party_size: number;
|
new_party_size: number;
|
||||||
new_party_strength: number;
|
new_party_strength: number;
|
||||||
|
health_losses?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardChallengeBout {
|
||||||
|
bout_number: number;
|
||||||
|
player_roll: number;
|
||||||
|
player_strength: number;
|
||||||
|
player_score: number;
|
||||||
|
wizard_roll: number;
|
||||||
|
wizard_strength: number;
|
||||||
|
wizard_score: number;
|
||||||
|
winner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardChallengeResult {
|
||||||
|
challenger_id: string;
|
||||||
|
challenger_name: string;
|
||||||
|
wizard_name?: string;
|
||||||
|
party_id?: string | null;
|
||||||
|
bouts: WizardChallengeBout[];
|
||||||
|
player_bouts_won: number;
|
||||||
|
wizard_bouts_won: number;
|
||||||
|
player_won: boolean;
|
||||||
|
score_change: number;
|
||||||
|
health_change: number;
|
||||||
|
new_score: number;
|
||||||
|
new_health: number;
|
||||||
|
wizard_respawn_position: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartyDefeatResult {
|
export interface PartyDefeatResult {
|
||||||
|
|
@ -199,6 +251,7 @@ export interface AiStepResponse {
|
||||||
move_result?: MoveResponse | null;
|
move_result?: MoveResponse | null;
|
||||||
formed_party?: Party | null;
|
formed_party?: Party | null;
|
||||||
battle_result?: BattleResult | null;
|
battle_result?: BattleResult | null;
|
||||||
|
wizard_challenge_result?: WizardChallengeResult | null;
|
||||||
game_concluded?: GameConclusion | null;
|
game_concluded?: GameConclusion | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Player } from '../types';
|
import type { Player } from '../types';
|
||||||
|
|
||||||
export type PieceType = 'knight' | 'warrior';
|
export type PieceType = 'knight' | 'warrior' | 'wizard';
|
||||||
|
|
||||||
// Hex color parser and manipulator
|
// Hex color parser and manipulator
|
||||||
function parseHex(hex: string): [number, number, number] {
|
function parseHex(hex: string): [number, number, number] {
|
||||||
|
|
@ -131,6 +131,25 @@ const LEADER_WARRIOR_SPRITE: string[] = [
|
||||||
'..____________..', // Row 15: Miniature drop shadow
|
'..____________..', // Row 15: Miniature drop shadow
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const WIZARD_SPRITE: string[] = [
|
||||||
|
'......CC........', // Row 0: Pointed wizard hat tip
|
||||||
|
'.....LCCD.......', // Row 1: Hat cone
|
||||||
|
'....LLCCDD......', // Row 2: Hat cone body
|
||||||
|
'...GLLCCDDG.gW..', // Row 3: Golden hat buckle + Staff crystal shine
|
||||||
|
'..KCCCCCCCCK.Gg.', // Row 4: Wide hat brim + Staff orb
|
||||||
|
'...KWWWWWWK..KH.', // Row 5: Face & white flowing beard + wood staff
|
||||||
|
'...KWBRBWK...KH.', // Row 6: Glowing mystic eyes + beard + staff
|
||||||
|
'.LCKWWWWWWK..KH.', // Row 7: Robe shoulders + beard + staff
|
||||||
|
'LCCKLLCCDDG..KH.', // Row 8: Robe body with golden clasp + staff
|
||||||
|
'DCDKLLCCDDK..KH.', // Row 9: Flowing robe + staff
|
||||||
|
'.DDKLLCCDDK..KH.', // Row 10: Robe lower + staff
|
||||||
|
'..K.KBBBBK.K.KH.', // Row 11: Mystic boots + staff base
|
||||||
|
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
|
||||||
|
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
|
||||||
|
'.KCCCCCCCCCCCCK.', // Row 14: Pedestal rim in robe color
|
||||||
|
'..____________..', // Row 15: Miniature drop shadow
|
||||||
|
];
|
||||||
|
|
||||||
// Palette generation
|
// Palette generation
|
||||||
function getPalette(color: string): Record<string, string> {
|
function getPalette(color: string): Record<string, string> {
|
||||||
const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
|
const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
|
||||||
|
|
@ -238,6 +257,8 @@ export function getSpriteCanvas(
|
||||||
let spriteMatrix: string[];
|
let spriteMatrix: string[];
|
||||||
if (pieceType === 'knight') {
|
if (pieceType === 'knight') {
|
||||||
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
|
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
|
||||||
|
} else if (pieceType === 'wizard') {
|
||||||
|
spriteMatrix = WIZARD_SPRITE;
|
||||||
} else {
|
} else {
|
||||||
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
|
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
|
||||||
}
|
}
|
||||||
|
|
@ -356,7 +377,8 @@ export function drawPlayerPiece(
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
|
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
|
||||||
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`;
|
const hpBadge = player.health !== undefined ? ` ❤️${player.health}` : '';
|
||||||
|
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpBadge}]`;
|
||||||
const textMetrics = ctx.measureText(text);
|
const textMetrics = ctx.measureText(text);
|
||||||
const bgWidth = textMetrics.width + 12;
|
const bgWidth = textMetrics.width + 12;
|
||||||
const bgHeight = 16;
|
const bgHeight = 16;
|
||||||
|
|
@ -377,6 +399,78 @@ export function drawPlayerPiece(
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canvas Drawing Helper for the Grand Wizard NPC
|
||||||
|
export function drawWizardPiece(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
wizard: { x: number; y: number; name: string; strength: number; color?: string },
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
cellSize: number
|
||||||
|
): void {
|
||||||
|
const color = wizard.color || '#A855F7';
|
||||||
|
const spriteCanvas = getSpriteCanvas('wizard', color, false);
|
||||||
|
|
||||||
|
const spriteSize = Math.max(cellSize * 1.45, 16);
|
||||||
|
const destX = Math.round(px - spriteSize / 2);
|
||||||
|
const destY = Math.round(py - spriteSize * 0.62);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// Glowing mystic arcane aura
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.65, spriteSize * 0.32, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(168, 85, 247, 0.25)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#c084fc';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.shadowColor = '#c084fc';
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw the pixelated Wizard
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
|
||||||
|
|
||||||
|
// Floating magic sparkle above wizard hat
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#fef08a';
|
||||||
|
ctx.font = `${Math.max(spriteSize * 0.4, 10)}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('✨', px, destY - 6);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Wizard Name & Strength Badge
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const bgWidth = textMetrics.width + 12;
|
||||||
|
const bgHeight = 16;
|
||||||
|
const labelY = destY - 16;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.94)';
|
||||||
|
ctx.strokeStyle = '#c084fc';
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.shadowColor = '#c084fc';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#e9d5ff';
|
||||||
|
ctx.fillText(text, px, labelY - 4);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
// React Component for displaying pixel avatar in UI
|
// React Component for displaying pixel avatar in UI
|
||||||
interface PixelAvatarProps {
|
interface PixelAvatarProps {
|
||||||
pieceType: PieceType;
|
pieceType: PieceType;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue