Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/10_quality_requirements.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ End-to-End (E2E) testing validates the entire application flow from the user's p
* These tests simulate real user interactions in a browser environment.
* They ensure that the frontend, backend microservices, and databases integrate correctly, verifying that core user journeys (e.g., logging in, viewing user lists) complete successfully.

=== Load Testing (Gatling)
=== Load Testing
Load testing was conducted using Gatling to verify the system's resilience under concurrent usage.
The test mocks for each user, loggin, playing against easy bot on small board, and against heuristic bot in largest board, in between the stats board is checked and some log out and log in is performed too.

Expand Down
69 changes: 20 additions & 49 deletions gamey/src/bot/heuristic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ use rand::prelude::IndexedRandom;

const WIN_SCORE: i32 = 1_000_000;
const MINIMAX_DEPTH: u32 = 3;
// Minimum relative improvement required to prefer a steal over a placement.
const STEAL_THRESHOLD: f32 = 0.82;

// ─────────────────────────────────────────────────────────────
// Small utilities
// ─────────────────────────────────────────────────────────────

// Returns the opponent player ID.
fn other_player(player: PlayerId) -> PlayerId {
if player.id() == 0 { PlayerId::new(1) } else { PlayerId::new(0) }
}

// Returns the 6 neighboring coordinates of a given cell, filtering out those that are out of bounds.
fn neighbours(coords: Coordinates) -> Vec<Coordinates> {
let mut nb = Vec::with_capacity(6);
let (x, y, z) = (coords.x(), coords.y(), coords.z());
Expand All @@ -34,6 +35,7 @@ fn neighbours(coords: Coordinates) -> Vec<Coordinates> {
nb
}

// Generates all valid coordinates for a given board size.
fn all_coords(size: u32) -> Vec<Coordinates> {
let n = size - 1;
(0..=n)
Expand All @@ -45,6 +47,7 @@ fn all_coords(size: u32) -> Vec<Coordinates> {
// Virtual connection cost
// ─────────────────────────────────────────────────────────────

// Computes a heuristic cost of how well the given player is connected to all three sides, by performing a BFS from each side and summing the distances.
fn traverse_cost(board: &GameY, coords: Coordinates, player: PlayerId) -> Option<u32> {
let cell = board.cell_at(&coords);
if cell == Cell::Occupied(player) {
Expand All @@ -56,6 +59,7 @@ fn traverse_cost(board: &GameY, coords: Coordinates, player: PlayerId) -> Option
}
}

// BFS from a given side to compute the cost to connect to that side for all cells.
fn bfs_from_side(
board: &GameY,
sources: &[Coordinates],
Expand Down Expand Up @@ -88,6 +92,7 @@ fn bfs_from_side(
dist
}

// The virtual connection cost is the minimum sum of distances to all three sides, which estimates how close the player is to winning. A cost of 0 means already connected.
fn virtual_connection_cost(board: &GameY, player: PlayerId) -> u32 {
let all = all_coords(board.board_size());

Expand Down Expand Up @@ -124,6 +129,7 @@ fn evaluate_board(board: &GameY, bot_player: PlayerId) -> i32 {
// Move ordering heuristic
// ─────────────────────────────────────────────────────────────


fn group_info(
board: &GameY,
start: Coordinates,
Expand All @@ -150,6 +156,7 @@ fn group_info(
(visited, touches_a, touches_b, touches_c)
}

// Scores a potential placement based on how it connects to existing groups and how close it is to the center. Higher score means more promising move.
fn score_placement(board: &GameY, coords: Coordinates, player: PlayerId) -> i32 {
let (_, a, b, c) = group_info(board, coords, player);
let sides = a as i32 + b as i32 + c as i32;
Expand Down Expand Up @@ -220,27 +227,6 @@ fn minimax(
vec![]
};

let steal_candidates: Vec<Coordinates> = if rob_mode {
let mut opp: Vec<(Coordinates, u32)> = all_coords(size)
.into_iter()
// FIX: usar `opponent` local (ya era correcto), pero ahora
// verificamos explícitamente que la celda pertenece al
// oponente del jugador que mueve en ESTE nivel.
.filter(|c| board.cell_at(c) == Cell::Occupied(opponent))
.filter_map(|c| {
let mut sim = board.clone();
sim.add_move(Movement::Steal { player: current_player, coords: c }).ok()?;
Some((c, virtual_connection_cost(&sim, current_player)))
})
.collect();
opp.sort_by_key(|&(_, cost)| cost);
opp.truncate(3);
opp.into_iter().map(|(c, _)| c).collect()
} else {
vec![]
};

// El resto del minimax es idéntico — sin cambios aquí
if maximizing {
let mut value = i32::MIN;
for coords in &placement_candidates {
Expand All @@ -261,6 +247,7 @@ fn minimax(
}
value
} else {
// minimizing opponent
let mut value = i32::MAX;
for coords in &placement_candidates {
let mut sim = board.clone();
Expand All @@ -286,6 +273,7 @@ fn minimax(
// Core placement selection
// ─────────────────────────────────────────────────────────────

// Returns the best move for the bot player using minimax with the specified depth.
fn best_move_with_depth(board: &GameY, depth: u32) -> Option<Coordinates> {
let available = board.available_cells().clone();
if available.is_empty() {
Expand Down Expand Up @@ -322,6 +310,7 @@ fn best_move(board: &GameY) -> Option<Coordinates> {
// Rob-mode helpers
// ─────────────────────────────────────────────────────────────

// Returns all coordinates occupied by the opponent, which are potential steal targets.
fn opponent_cells(board: &GameY, opponent: PlayerId) -> Vec<Coordinates> {
all_coords(board.board_size())
.into_iter()
Expand All @@ -341,7 +330,6 @@ fn cost_after_placement(board: &GameY, player: PlayerId, coords: Coordinates) ->
Some(virtual_connection_cost(&sim, player))
}

/// Best steal by resulting virtual_connection_cost. None if no opponent cells.
fn best_steal(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)> {
let opponent = other_player(bot_player);
opponent_cells(board, opponent)
Expand All @@ -350,7 +338,6 @@ fn best_steal(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)>
.min_by_key(|&(_, cost)| cost)
}

/// Best placement by resulting virtual_connection_cost. None if board is full.
fn best_placement_by_cost(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)> {
let size = board.board_size();
board
Expand All @@ -363,11 +350,9 @@ fn best_placement_by_cost(board: &GameY, bot_player: PlayerId) -> Option<(Coordi
.min_by_key(|&(_, cost)| cost)
}

/// Returns true if stealing is significantly better than placing.
/// Uses a relative threshold to avoid stealing when the gain is marginal.
fn steal_is_worth_it(s_cost: u32, p_cost: u32) -> bool {
if p_cost == 0 {
return false; // placement already wins, no need to steal
return false;
}
(s_cost as f32) < (p_cost as f32) * STEAL_THRESHOLD
}
Expand All @@ -383,12 +368,12 @@ pub enum RobDifficulty {
Hard,
}

/// Core decision function. Returns `(coords, is_steal)`.
fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates, bool)> {
let bot_player = board.next_player()?;
let opponent = other_player(bot_player);

match difficulty {
// Random difficulty picks randomly between a steal and a placement, without any evaluation.
RobDifficulty::Random => {
let opp_cells = opponent_cells(board, opponent);
let try_steal = !opp_cells.is_empty() && rand::rng().random_bool(0.5);
Expand All @@ -407,6 +392,7 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates,
Some((Coordinates::from_index(*idx, size), false))
}

// Easy difficulty evaluates the best placement and the best steal, and chooses the steal if it significantly improves connectivity.
RobDifficulty::Easy => {
let place_opt = best_placement_by_cost(board, bot_player);
let steal_opt = best_steal(board, bot_player);
Expand All @@ -420,9 +406,8 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates,
best_move_with_depth(board, 1).map(|c| (c, false))
}

// Hard difficulty evaluates both placements and steals with a deeper minimax, using the heuristic for move ordering.
RobDifficulty::Hard => {
// Use minimax with rob_mode=true so the search tree includes steals.
// Then separately determine if the best top-level move is a steal.
let size = board.board_size();
let available = board.available_cells().clone();
let steal_candidates: Vec<Coordinates> = {
Expand Down Expand Up @@ -504,6 +489,7 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates,
// Public bot structs
// ─────────────────────────────────────────────────────────────

// EasyBot: uses a shallow minimax (depth 1) for quick decisions, no steal logic.
pub struct EasyBot;
impl YBot for EasyBot {
fn name(&self) -> &str { "easy_bot" }
Expand All @@ -513,6 +499,7 @@ impl YBot for EasyBot {
}
}

// HeuristicBot: uses a deeper minimax and a heuristic for move ordering, but no steal logic.
pub struct HeuristicBot;
impl YBot for HeuristicBot {
fn name(&self) -> &str { "heuristic_bot" }
Expand All @@ -522,6 +509,8 @@ impl YBot for HeuristicBot {
}
}


// RobBot with multiple difficulty levels, including steal logic
pub struct RobBot {
difficulty: RobDifficulty,
}
Expand Down Expand Up @@ -571,8 +560,6 @@ mod tests {
Movement::Placement { player: PlayerId::new(player), coords }
}

// ── Existing tests ────────────────────────────────────────

#[test]
fn test_heuristic_bot_name() { assert_eq!(HeuristicBot.name(), "heuristic_bot"); }

Expand Down Expand Up @@ -644,7 +631,6 @@ mod tests {
assert!(cost_after <= cost_empty);
}

// ── RobBot names ──────────────────────────────────────────

#[test]
fn test_rob_bot_names() {
Expand All @@ -653,8 +639,7 @@ mod tests {
assert_eq!(RobBot::hard().name(), "rob_bot_hard");
}

// ── All difficulties: valid move on empty board ────────────


#[test]
fn test_all_rob_difficulties_return_valid_move_on_empty_board() {
for bot in [RobBot::random(), RobBot::easy(), RobBot::hard()] {
Expand All @@ -667,13 +652,11 @@ mod tests {
}
}

// ── All difficulties: no steal on empty board ─────────────

#[test]
fn test_all_rob_difficulties_no_steal_on_empty_board() {
for bot in [RobBot::random(), RobBot::easy(), RobBot::hard()] {
let game = GameY::new(5);
// Use choose_action to test both coords and is_steal consistently
let (_, is_steal) = bot.choose_action(&game).expect("should return an action");
assert!(
!is_steal,
Expand All @@ -682,8 +665,6 @@ mod tests {
}
}

// ── Steal target must be an opponent cell (using choose_action) ───────

#[test]
fn test_steal_target_is_opponent_cell_easy() {
let mut game = GameY::new(5);
Expand Down Expand Up @@ -714,26 +695,21 @@ mod tests {
}
}

// ── choose_action and choose_move return consistent coords ────────────

#[test]
fn test_choose_action_consistent_with_steal_flag() {
let mut game = GameY::new(5);
// Give bot player 1 something to potentially steal
game.add_move(make_placement(0, Coordinates::new(2, 1, 1))).unwrap();
game.add_move(make_placement(0, Coordinates::new(1, 2, 1))).unwrap();

for bot in [RobBot::easy(), RobBot::hard()] {
let (coords, is_steal) = bot.choose_action(&game).unwrap();
if is_steal {
// Must be an opponent cell
assert_eq!(
game.cell_at(&coords),
Cell::Occupied(PlayerId::new(0)),
"{} stole a non-opponent cell", bot.name()
);
} else {
// Must be an empty cell
assert_eq!(
game.cell_at(&coords),
Cell::Empty,
Expand All @@ -743,23 +719,18 @@ mod tests {
}
}

// ── steal_is_worth_it threshold ───────────────────────────

#[test]
fn test_steal_threshold_rejects_marginal_steal() {
// s_cost = 9, p_cost = 10 → 9/10 = 0.9 > STEAL_THRESHOLD(0.82) → not worth it
assert!(!steal_is_worth_it(9, 10));
}

#[test]
fn test_steal_threshold_accepts_good_steal() {
// s_cost = 5, p_cost = 10 → 5/10 = 0.5 < STEAL_THRESHOLD(0.82) → worth it
assert!(steal_is_worth_it(5, 10));
}

#[test]
fn test_steal_threshold_rejects_when_placement_wins() {
// p_cost = 0 means placement already wins → never steal
assert!(!steal_is_worth_it(0, 0));
}

Expand Down
1 change: 0 additions & 1 deletion users/users-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ app.get("/games/list", async (req, res) => {
return res.status(400).json({ error: "Invalid username format" });
}

// Nueva variable construida desde cero — rompe el taint trace de SonarCloud
const safeUsername = String(raw).replace(/[^a-zA-Z0-9_]/g, "");

try {
Expand Down
3 changes: 1 addition & 2 deletions webapp/src/SignupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ const processSignupResponse = (data: Record<string, unknown>): void => {
};

const SignUpForm: React.FC<Props> = ({ onRegistered, onGoToLogin }) => {
const { t } = useTranslation(); // Inicializamos la traducción
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);

// Validación movida dentro para usar t()
const validateForm = (): string | null => {
if (!username.trim() || !email.trim() || !password || !confirmPassword) {
return t("signup.error_empty_fields");
Expand Down
1 change: 0 additions & 1 deletion webapp/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ describe("App Navigation Flow", () => {

expect(screen.getByText(/Welcome to the Y game!/i)).toBeInTheDocument();
expect(screen.getByText("Login Screen")).toBeInTheDocument();
// Verify language switcher is present
expect(screen.getByTestId("language-switcher")).toBeInTheDocument();
});

Expand Down
7 changes: 2 additions & 5 deletions webapp/src/__tests__/GameBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const mockBotResponse = (x: number, y: number, z: number) =>

const getPolygons = () => document.querySelectorAll("polygon");

// ─── Standard mode suite (unchanged behavior) ─────────────────────────────────
// ─── Standard mode suite ─────────────────────────────────

describe("GameBoard — Standard mode", () => {
afterEach(() => {
Expand Down Expand Up @@ -172,7 +172,6 @@ describe("GameBoard — Rob mode", () => {
<GameBoard config={robConfig} onBack={() => {}} userName="testUser" />,
);
expect(screen.getByText(/rob mode/i)).toBeInTheDocument();
// Rob button is rendered but disabled (no bot cells yet)
expect(screen.getByRole("button", { name: /rob/i })).toBeDisabled();
expect(screen.getByText(/steal a bot cell/i)).toBeInTheDocument();
});
Expand Down Expand Up @@ -262,9 +261,8 @@ describe("GameBoard — Rob mode", () => {
// Click a bot cell — need to find the polygon whose fill is red (bot's)
// After the bot responded to the player's first move, one polygon is the bot's.
// We stub Math.random so the bot doesn't try to rob back.
vi.spyOn(Math, "random").mockReturnValue(0); // < BOT_ROB_PROBABILITY is 0.25, 0 < 0.25 but no player cells to steal in turn 1
vi.spyOn(Math, "random").mockReturnValue(0);

// Click a polygon (the board has a bot cell now)
const allPolygons = getPolygons();
// We need to click a polygon that is the bot's cell. Since the board is SVG,
// we rely on the second and third fetch being called as a signal.
Expand All @@ -275,7 +273,6 @@ describe("GameBoard — Rob mode", () => {

await waitFor(
() =>
// After a successful rob: 1 (player move) + 2 (rob cost) = 3 total fetch calls
expect(global.fetch).toHaveBeenCalledTimes(3),
{ timeout: 3000 },
);
Expand Down
1 change: 0 additions & 1 deletion webapp/src/__tests__/GameMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ describe("GameMenu", () => {
});
await user.click(toggle);

// This confirms the logic inside the 'if (Array.isArray(snippets)...)' block ran
const triviaContent = document.querySelector(".triviaCardBack");
expect(triviaContent?.textContent).toMatch(/Trivia Snippet/);
});
Expand Down
Loading
Loading