Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/gameplay/gameEngine/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,17 @@ class Game extends Room {
}
}

// After roles are assigned, set displayRole for deceptive roles (Melron/Moregano).
for (let i = 0; i < this.playersInGame.length; i++) {
const p = this.playersInGame[i];
if (p.role === Role.Melron) {
p.displayRole = Role.Merlin; // Melron thinks they are Merlin
} else if (p.role === Role.Moregano) {
p.displayRole = Role.Morgana; // Moregano thinks they are Morgana
}
}
Comment on lines +506 to +514
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indenting is a bit off.

Will need to confirm if .displayRole is the correct thing to be overriding.



// Prepare the data for each person to see for the rest of the game.
// The following data do not change as the game goes on.
for (let i = 0; i < this.playersInGame.length; i++) {
Expand Down
51 changes: 35 additions & 16 deletions src/gameplay/gameEngine/phases/common/votingMission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import usernamesIndexes from '../../../../myFunctions/usernamesIndexes';
import { ButtonSettings, IPhase, Phase } from '../types';
import { Alliance } from '../../types';
import { SocketUser } from '../../../../sockets/types';
import { Role } from '../../roles/types';


class VotingMission implements IPhase {
static phase = Phase.VotingMission;
Expand Down Expand Up @@ -32,22 +34,39 @@ class VotingMission implements IPhase {
)
] = 'succeed';
// console.log("received succeed from " + socket.request.user.username);
} else if (buttonPressed === 'no') {
// If the user is a res, they shouldn't be allowed to fail
const index = usernamesIndexes.getIndexFromUsername(
this.thisRoom.playersInGame,
socket.request.user.username,
);
if (
index !== -1 &&
this.thisRoom.playersInGame[index].alliance === Alliance.Resistance
) {
socket.emit(
'danger-alert',
'You are resistance! Surely you want to succeed!',
);
return;
}
} else if (buttonPressed === 'no') {
// Determine the player index
const index = usernamesIndexes.getIndexFromUsername(
this.thisRoom.playersInGame,
socket.request.user.username,
);

// If player is Resistance and NOT Moregano, block failing
if (
index !== -1 &&
this.thisRoom.playersInGame[index].alliance === Alliance.Resistance &&
this.thisRoom.playersInGame[index].role !== Role.Moregano
Copy link
Owner

@vck3000 vck3000 Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, will need to check whether this will subtly break the timer. This code path is actually now dead and should never happen, as the buttonsAvailable should never show fail as an option if you're res.

I'll have a think about how to handle this.

Edit: This won't be an issue if

) {
socket.emit(
'danger-alert',
'You are resistance! Surely you want to succeed!',
);
return;
}

// If player is Moregano and pressed "no", silently record "succeed".
const effectiveVote =
index !== -1 && this.thisRoom.playersInGame[index].role === Role.Moregano
? 'succeed'
: 'fail';

this.thisRoom.missionVotes[
usernamesIndexes.getIndexFromUsername(
this.thisRoom.playersInGame,
socket.request.user.username,
)
] = effectiveVote;


this.thisRoom.missionVotes[
usernamesIndexes.getIndexFromUsername(
Expand Down
79 changes: 79 additions & 0 deletions src/gameplay/gameEngine/roles/avalon/melron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Alliance, See } from '../../types';
import { IRole, Role } from '../types';
import Game from '../../game';

/**
* Melron (Resistance) — believes they are Merlin.
* Sees a RANDOM set of players as spies: size mirrors Merlin’s count
* (real spies minus 1 if Mordred/MordredAssassin is in play).
* Percival does NOT see Melron.
*/
class Melron implements IRole {
room: Game;

static role = Role.Melron;
role = Role.Melron;

alliance = Alliance.Resistance;

description = 'Thinks they are Merlin; sees a random “spy” list mirroring Merlin’s count.';
orderPriorityInOptions = 75; // place near Percival/Morgana if you care about ordering

specialPhase: string;

constructor(thisRoom: any) {
this.room = thisRoom;
}

see(): See {
const spies: string[] = [];
if (!this.room.gameStarted) return { spies, roleTags: {} };

// Count real spies and detect Mordred/MordredAssassin (Merlin doesn’t see them)
let realSpyCount = 0;
let hasMordred = false;

for (let i = 0; i < this.room.playersInGame.length; i++) {
const p = this.room.playersInGame[i];
if (p.alliance === Alliance.Spy) {
realSpyCount++;
if (p.role === Role.Mordred || p.role === Role.MordredAssassin) {
hasMordred = true;
}
}
}

const k = Math.max(0, realSpyCount - (hasMordred ? 1 : 0));

// Build pool of all non-self usernames
const self = this.getSelfUsername();
const pool = this.room.playersInGame
.map((p: any) => p.username)
.filter((u: string) => u !== self);

// Shuffle pool (Fisher–Yates) and pick k
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
const picks = pool.slice(0, k);

for (const u of picks) spies.push(this.room.anonymizer.anon(u));

return { spies, roleTags: {} };
Comment on lines +48 to +63
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we'll need to do this on game start and remember the spies we've built up.
See() (if I recall correctly) is called on every game move, so we wouldn't want these to be shuffling mid-game.

}

private getSelfUsername(): string {
for (let i = 0; i < this.room.playersInGame.length; i++) {
if (this.room.playersInGame[i].role === Role.Melron) {
return this.room.playersInGame[i].username;
}
}
return '';
}

checkSpecialMove(): void {}
getPublicGameData(): any {}
}

export default Melron;
85 changes: 85 additions & 0 deletions src/gameplay/gameEngine/roles/avalon/moregano.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Alliance, See } from '../../types';
import { IRole, Role } from '../types';
import Game from '../../game';

/**
* Moregano (Resistance) — believes they are Morgana.
* Sees a FAKE spy team that MUST include self; size mirrors Morgana’s count
* (real spies minus Oberon).
* If they press "Fail", it is silently processed as "Succeed".
*/
class Moregano implements IRole {
room: Game;

static role = Role.Moregano;
role = Role.Moregano;

alliance = Alliance.Resistance;

description = 'Thinks they are Morgana; sees a fake spy team; their Fail counts as Success.';
orderPriorityInOptions = 72;

specialPhase: string;

constructor(thisRoom: any) {
this.room = thisRoom;
}

see(): See {
const spies: string[] = [];
if (!this.room.gameStarted) return { spies, roleTags: {} };

// Count real spies and detect Oberon (Morgana doesn’t see Oberon)
let realSpyCount = 0;
let hasOberon = false;

for (let i = 0; i < this.room.playersInGame.length; i++) {
const p = this.room.playersInGame[i];
if (p.alliance === Alliance.Spy) {
realSpyCount++;
if (p.role === Role.Oberon) hasOberon = true;
}
}

const k = Math.max(1, realSpyCount - (hasOberon ? 1 : 0)); // include self, so at least 1

const self = this.getSelfUsername();
if (!self) return { spies, roleTags: {} };

// Start with self
spies.push(this.room.anonymizer.anon(self));

const othersNeeded = k - 1;
if (othersNeeded <= 0) return { spies, roleTags: {} };

// Pool of non-self usernames
const pool = this.room.playersInGame
.map((p: any) => p.username)
.filter((u: string) => u !== self);

// Shuffle pool and pick othersNeeded
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
const picks = pool.slice(0, othersNeeded);

for (const u of picks) spies.push(this.room.anonymizer.anon(u));

return { spies, roleTags: {} };
}

private getSelfUsername(): string {
for (let i = 0; i < this.room.playersInGame.length; i++) {
if (this.room.playersInGame[i].role === Role.Moregano) {
return this.room.playersInGame[i].username;
}
}
return '';
}

checkSpecialMove(): void {}
getPublicGameData(): any {}
}

export default Moregano;
7 changes: 7 additions & 0 deletions src/gameplay/gameEngine/roles/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import Spy from './avalon/spy';
import Mordred from './avalon/mordred';
import MordredAssassin from './avalon/mordredassassin';
import Hitberon from './avalon/hitberon';
import Melron from './avalon/melron';
import Moregano from './avalon/moregano';


type Class<I, Args extends any[] = any[]> = new (...args: Args) => I;
export const avalonRoles: Record<string, Class<IRole>> = {
Expand All @@ -32,6 +35,10 @@ export const avalonRoles: Record<string, Class<IRole>> = {

[MordredAssassin.role]: MordredAssassin,
[Hitberon.role]: Hitberon,

[Melron.role]: Melron,
[Moregano.role]: Moregano,

};

export const rolesThatCantGuessMerlin = [
Expand Down
4 changes: 4 additions & 0 deletions src/gameplay/gameEngine/roles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export enum Role {

MordredAssassin = 'MordredAssassin',
Hitberon = 'Hitberon',

// ADD THESE TWO
Melron = 'Melron',
Moregano = 'Moregano',
}

export interface IRole {
Expand Down