File size: 8,261 Bytes
6d09523
96dfcfb
19c41e0
 
 
 
 
 
 
 
 
 
96dfcfb
 
 
 
 
 
 
 
 
 
 
19c41e0
 
 
6d09523
19c41e0
6d09523
19c41e0
6d09523
 
 
 
 
 
 
 
19c41e0
 
6d09523
 
19c41e0
6d09523
19c41e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d09523
19c41e0
 
1f0b59d
19c41e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96dfcfb
1f0b59d
96dfcfb
1f0b59d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19c41e0
 
 
 
96dfcfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19c41e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d09523
19c41e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import { Browser, Page, chromium, BrowserContext } from 'playwright';
import { ICodenamesGameAPI, BoardState, Clue, Team, BoardWord } from '../shared/types';
import { Selectors } from './selectors';

const BASE_URL = process.env.CODENAMES_BASE_URL;
const ROOM_CODE = process.env.CODENAMES_TEST_ROOM_CODE;

function getRoomUrl() {
  if (!BASE_URL || !ROOM_CODE) throw new Error('CODENAMES_BASE_URL and CODENAMES_TEST_ROOM_CODE must be set');
  return `${BASE_URL}${ROOM_CODE}`;
}

// Utility function to map color string to Team enum
function mapColorToTeam(color: string | null): Team | null {
  switch (color) {
    case 'red': return Team.Red;
    case 'blue': return Team.Blue;
    case 'gray': return Team.Neutral;
    case 'black': return Team.Assassin;
    default: return null;
  }
}

export class CodenamesGameHandler implements ICodenamesGameAPI {
  private browser: Browser;
  private page: Page;
  private context: BrowserContext | null = null;

  private constructor(page: Page, browser?: Browser, context?: BrowserContext) {
    this.page = page;
    if (context) {
      this.context = context;
      this.browser = context.browser()!;
    } else if (browser) {
      this.browser = browser;
    } else {
      throw new Error("Either Browser or BrowserContext must be provided");
    }
  }

  static async createInContext(context: BrowserContext): Promise<CodenamesGameHandler> {
    const page = await context.newPage();
    await page.goto(getRoomUrl());
    return new CodenamesGameHandler(page, undefined, context);
  }

  async joinRoom(): Promise<void> {
    // Wait for loading to disappear
    try {
      await this.page.waitForSelector(Selectors.loadingSelector, { state: 'hidden', timeout: 30000 });
    } catch (e) {
      await this._handleScreenshot('screenshots/joinRoom_loading_timeout.png', '[joinRoom] Loading screen did not disappear after 30s');
      throw e;
    }
    const nicknameInput = this.page.locator(Selectors.nicknameInput);
    if (await nicknameInput.isVisible({ timeout: 10000 }).catch(async () => {
      await this._handleScreenshot('screenshots/joinRoom_no_nickname.png', '[joinRoom] Nickname input not visible, skipping join');
      return false;
    })) {
      await nicknameInput.fill(`TestPlayer${Math.floor(Math.random() * 10000)}`);
      const joinButton = this.page.locator(Selectors.joinRoomButton);
      await joinButton.waitFor({ timeout: 10000 });
      console.log(`>>> Clicking joinButton`);
      await joinButton.click();
    }
  }

  async leaveRoom(): Promise<void> {
    const profileMenuButton = this.page.locator(Selectors.profileMenuButton);
    await profileMenuButton.waitFor({ timeout: 5000 });
    await profileMenuButton.click();
    const leaveRoomButton = this.page.locator(Selectors.leaveRoomButton);
    await leaveRoomButton.waitFor({ timeout: 5000 });
    const prevUrl = this.page.url();
    await leaveRoomButton.click();
    await this.page.waitForFunction((oldUrl) => window.location.href !== oldUrl, prevUrl, { timeout: 5000 });
  }

  async joinAsSpymaster(team: Team): Promise<void> {
    console.log(`>>> Entering joinAsSpymaster for team: ${team === Team.Red ? 'Red' : 'Blue'}`);
    const teamBoardSelector = team === Team.Red ? Selectors.teamBoardRed : Selectors.teamBoardBlue;
    const joinSpymasterButtonSelector = team === Team.Red ? Selectors.joinRedSpymasterButton : Selectors.joinBlueSpymasterButton;

    try {
      // Wait for the team board (redundant if button check passed, but safe)
      await this.page.waitForSelector(teamBoardSelector, { state: 'visible', timeout: 5000 });
    } catch (e) {
      // This specific catch might be less likely now, but keep for safety
      await this._handleScreenshot('screenshots/joinSpymaster_teamBoard_timeout_alt.png', `[joinAsSpymaster] Team board (${teamBoardSelector}) did not become visible even after button check`);
      throw e;
    }

    // First, check if the join button is even visible. If not, assume role is taken/player already joined.
    await new Promise(resolve => setTimeout(resolve, 1000));
    const isJoinButtonVisible = await this.page.locator(joinSpymasterButtonSelector).isVisible({ timeout: 20000 }).catch(() => false);

    if (!isJoinButtonVisible) {
      // console.log(`Join Spymaster button (${joinSpymasterButtonSelector}) is not visible. Assuming already joined or role unavailable.`);
      await this._handleScreenshot('screenshots/joinSpymaster_no_button.png', `[joinAsSpymaster] Join button (${joinSpymasterButtonSelector}) not visible`);
      return; // Exit early
    }
    
    console.log(`Join Spymaster button (${joinSpymasterButtonSelector}) is visible. Attempting to join.`);
    const joinSpymasterButton = this.page.locator(joinSpymasterButtonSelector);

    try {
      console.log(`>>> Clicking joinSpymasterButton`);
      await joinSpymasterButton.click();
    } catch (e) {
      await this._handleScreenshot('screenshots/joinSpymaster_click_or_hidden_timeout_alt.png', `[joinAsSpymaster] Error clicking button (${joinSpymasterButtonSelector})`);
      throw e;
    }
    await joinSpymasterButton.waitFor({ state: 'hidden', timeout: 5000 });
  }

  async getBoardState(): Promise<BoardState> {
    // Only extract word and color in the browser context
    const rawCards = await this.page.$$eval(
      Selectors.boardCard,
      (cardElements, wordSelector) =>
        Array.from(cardElements).map(cardElement => {
          const color = cardElement.getAttribute('aria-label');
          const wordElement = cardElement.querySelector(wordSelector);
          const word = wordElement && wordElement.textContent ? wordElement.textContent : '';
          return { word: word.trim(), color };
        }),
      Selectors.boardCardWord
    );

    // Map to BoardWord with enum and revealed
    const cards: BoardWord[] = rawCards.map(({ word, color }) => ({
      word,
      revealed: false,
      team: mapColorToTeam(color),
    }));

    return { words: cards };
  }

  async submitClue(clue: Clue): Promise<void> {
    const clueInput = this.page.locator(Selectors.clueInput);
    const numberSelectorTrigger = this.page.locator(Selectors.numberSelectorTrigger);
    const giveClueButton = this.page.locator(Selectors.giveClueButton);
    await clueInput.fill(clue.word);
    await numberSelectorTrigger.click();
    const numberButton = this.page.locator(Selectors.numberOption(clue.count));
    await numberButton.waitFor({ timeout: 5000 });
    await numberButton.click();
    await giveClueButton.click();
  }

  async makeGuess(word: string): Promise<void> {
    // TODO: Implement clicking the board word
  }

  async endTurn(): Promise<void> {
    const endTurnButton = this.page.locator(Selectors.endTurnButton);
    await endTurnButton.click();
  }

  // Internal/test-only resource cleanup
  async _cleanupResources(): Promise<void> {
    if (this.page && !this.page.isClosed()) {
      await this.page.close();
    }
  }

  // Internal: Check if the join spymaster button is visible for a team
  async _isJoinSpymasterButtonVisible(team: 'red' | 'blue'): Promise<boolean> {
    const selector = team === 'red' ? Selectors.joinRedSpymasterButton : Selectors.joinBlueSpymasterButton;
    const button = this.page.locator(selector);
    return await button.isVisible().catch(() => false);
  }

  // Internal: Check if the clue input is visible
  async _isClueInputVisible(): Promise<boolean> {
    const clueInput = this.page.locator(Selectors.clueInput);
    return await clueInput.isVisible().catch(() => false);
  }

  // Internal: Wait for the clue input to become hidden
  async _waitForClueInputHidden(timeout = 5000): Promise<void> {
    const clueInput = this.page.locator(Selectors.clueInput);
    try {
      await clueInput.waitFor({ state: 'hidden', timeout });
    } catch (e) {
      await this._handleScreenshot('screenshots/waitForClueInputHidden_timeout.png', '[waitForClueInputHidden] Clue input did not become hidden in time');
      throw e;
    }
  }

  // Internal: Utility to take a screenshot and log a message (for timeouts/errors)
  private async _handleScreenshot(path: string, logMessage?: string): Promise<void> {
    if (logMessage) console.log(logMessage);
    await this.page.screenshot({ path });
  }
}