Skip to content

Commit 270bd24

Browse files
committed
✨ (viewer) make replay more permissive
1 parent a47ce9f commit 270bd24

18 files changed

+373
-105
lines changed

engine/src/available-command.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("Available commands", () => {
9898
const player = { data: d } as Player;
9999

100100
function possible() {
101-
return possibleBoardActions(actions, player)[0].data.poweracts.map((a) => a.name);
101+
return possibleBoardActions(actions, player, false)[0].data.poweracts.map((a) => a.name);
102102
}
103103

104104
expect(possible()).to.include("power1");

engine/src/available-command.ts

+14-12
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export function generate(engine: Engine, subPhase: SubPhase = null, data?: any):
205205
...possibleBuildings(engine, player),
206206
...possibleFederations(engine, player),
207207
...possibleResearchAreas(engine, player, UPGRADE_RESEARCH_COST),
208-
...possibleBoardActions(engine.boardActions, engine.player(player)),
208+
...possibleBoardActions(engine.boardActions, engine.player(player), engine.replay),
209209
...possibleSpecialActions(engine, player),
210210
...possibleFreeActions(engine.player(player)),
211211
...possibleRoundBoosters(engine, player),
@@ -278,14 +278,15 @@ function addPossibleNewPlanet(
278278
planet: Planet,
279279
building: Building,
280280
buildings: AvailableBuilding[],
281-
lastRound: boolean
281+
lastRound: boolean,
282+
replay: boolean
282283
) {
283-
const qicNeeded = qicForDistance(map, hex, pl);
284+
const qicNeeded = qicForDistance(map, hex, pl, replay);
284285
if (qicNeeded === null) {
285286
return;
286287
}
287288

288-
const check = pl.canBuild(planet, building, lastRound, {
289+
const check = pl.canBuild(planet, building, lastRound, replay, {
289290
addedCost: [new Reward(qicNeeded.amount, Resource.Qic)],
290291
});
291292

@@ -368,9 +369,10 @@ export function possibleBuildings(engine: Engine, player: Player): AvailableComm
368369
const upgraded = upgradedBuildings(building, engine.player(player).faction);
369370

370371
for (const upgrade of upgraded) {
371-
const check = engine
372-
.player(player)
373-
.canBuild(hex.data.planet, upgrade, engine.isLastRound, { isolated, existingBuilding: building });
372+
const check = engine.player(player).canBuild(hex.data.planet, upgrade, engine.isLastRound, engine.replay, {
373+
isolated,
374+
existingBuilding: building,
375+
});
374376
if (check) {
375377
buildings.push(newAvailableBuilding(upgrade, hex, check, true));
376378
}
@@ -382,7 +384,7 @@ export function possibleBuildings(engine: Engine, player: Player): AvailableComm
382384
// No need for terra forming if already occupied by another faction
383385
const planet = hex.occupied() ? pl.planet : hex.data.planet;
384386
const building = hex.data.planet === Planet.Transdim ? Building.GaiaFormer : Building.Mine;
385-
addPossibleNewPlanet(map, hex, pl, planet, building, buildings, engine.isLastRound);
387+
addPossibleNewPlanet(map, hex, pl, planet, building, buildings, engine.isLastRound, engine.replay);
386388
}
387389
} // end for hex
388390

@@ -412,7 +414,7 @@ export function possibleSpaceStations(engine: Engine, player: Player): Available
412414
}
413415

414416
const building = Building.SpaceStation;
415-
addPossibleNewPlanet(map, hex, pl, pl.planet, building, buildings, engine.isLastRound);
417+
addPossibleNewPlanet(map, hex, pl, pl.planet, building, buildings, engine.isLastRound, engine.replay);
416418
}
417419

418420
if (buildings.length > 0) {
@@ -494,7 +496,7 @@ export function possibleSpecialActions(engine: Engine, player: Player) {
494496
return commands;
495497
}
496498

497-
export function possibleBoardActions(actions: BoardActions, p: PlayerObject): AvailableCommand[] {
499+
export function possibleBoardActions(actions: BoardActions, p: PlayerObject, replay: boolean): AvailableCommand[] {
498500
const commands: AvailableCommand[] = [];
499501

500502
// not allowed if everything is lost - see https://github.com/boardgamers/gaia-project/issues/76
@@ -512,7 +514,7 @@ export function possibleBoardActions(actions: BoardActions, p: PlayerObject): Av
512514
(pwract) =>
513515
actions[pwract] === null &&
514516
p.data.canPay(Reward.parse(boardActions[pwract].cost)) &&
515-
boardActions[pwract].income.some((income) => Reward.parse(income).some((reward) => canGain(reward)))
517+
boardActions[pwract].income.some((income) => Reward.parse(income).some((reward) => replay || canGain(reward)))
516518
);
517519

518520
// Prevent using the rescore action if no federation token
@@ -687,7 +689,7 @@ export function possibleSpaceLostPlanet(engine: Engine, player: Player) {
687689
if (hex.data.planet !== Planet.Empty || hex.data.federations || hex.data.building) {
688690
continue;
689691
}
690-
const qicNeeded = qicForDistance(engine.map, hex, p);
692+
const qicNeeded = qicForDistance(engine.map, hex, p, engine.replay);
691693

692694
if (qicNeeded.amount > data.qics) {
693695
continue;

engine/src/cost.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe("cost", () => {
131131
player: PlayerEnum.Player1,
132132
} as Player;
133133

134-
const qic = qicForDistance(m, m.getS(g.location), p);
134+
const qic = qicForDistance(m, m.getS(g.location), p, false);
135135
expect(qic).to.deep.equal(test.want);
136136
});
137137
}
@@ -179,7 +179,7 @@ describe("cost", () => {
179179
it(test.name, () => {
180180
const g = test.give;
181181
const p = { temporaryStep: g.temporaryStep, terraformCostDiscount: g.discount } as PlayerData;
182-
const cost = terraformingCost(p, g.steps);
182+
const cost = terraformingCost(p, g.steps, false);
183183
expect(cost).to.deep.equal(test.want.cost);
184184
});
185185
}

engine/src/cost.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import Reward from "./reward";
88
const TERRAFORMING_COST = 3;
99
const QIC_RANGE_UPGRADE = 2;
1010

11-
export function terraformingCost(d: PlayerData, steps: number): Reward | null {
11+
export function terraformingCost(d: PlayerData, steps: number, replay: boolean): Reward | null {
1212
const oreNeeded = (temporaryStep: number) =>
1313
(TERRAFORMING_COST - d.terraformCostDiscount) * Math.max(steps - temporaryStep, 0);
1414

1515
const cost = oreNeeded(d.temporaryStep);
16-
if (d.temporaryStep > 0 && oreNeeded(0) === cost) {
16+
if (!replay && d.temporaryStep > 0 && oreNeeded(0) === cost) {
1717
// not allowed - see https://github.com/boardgamers/gaia-project/issues/76
1818
// OR (for booster) there's no reason to activate the booster and not use it
1919
return null;
@@ -23,7 +23,7 @@ export function terraformingCost(d: PlayerData, steps: number): Reward | null {
2323

2424
export type QicNeeded = { amount: number; distance: number; warning?: BuildWarning } | null;
2525

26-
export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject): QicNeeded {
26+
export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject, replay: boolean): QicNeeded {
2727
const distance = (acceptGaiaFormer: boolean) => {
2828
const hexes = acceptGaiaFormer
2929
? Array.from(map.grid.values()).filter((loc) => loc.data.player === pl.player)
@@ -37,7 +37,7 @@ export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject): Q
3737

3838
const d = distance(false);
3939
const qicNeeded = qic(pl.data.temporaryRange, d);
40-
if (pl.data.temporaryRange > 0 && qic(0, distance(false)) === qicNeeded) {
40+
if (!replay && pl.data.temporaryRange > 0 && qic(0, distance(false)) === qicNeeded) {
4141
// there's no reason to activate the booster and not use it
4242
return null;
4343
}

engine/src/engine.ts

+47-22
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export default class Engine {
227227
oldPhase: Phase;
228228
randomFactions?: Faction[];
229229
version = version;
230+
replayVersion: string;
231+
replay: boolean; // be more permissive during replay
230232

231233
get expansions() {
232234
return 0;
@@ -266,13 +268,19 @@ export default class Engine {
266268
// Tells the UI if the new move should be on the same line or not
267269
newTurn = true;
268270

269-
constructor(moves: string[] = [], options: EngineOptions = {}, engineVersion?: string) {
271+
constructor(moves: string[] = [], options: EngineOptions = {}, engineVersion?: string, replay = false) {
270272
this.options = options;
271273
if (engineVersion) {
272274
this.version = engineVersion;
273275
}
276+
this.replay = replay;
277+
if (replay) {
278+
this.options.noFedCheck = true;
279+
this.options.flexibleFederations = true;
280+
}
274281
this.sanitizeOptions();
275282
this.loadMoves(moves);
283+
this.options = options;
276284
}
277285

278286
/** Fix old options passed. To remove when legacy data is no more in database */
@@ -311,12 +319,23 @@ export default class Engine {
311319
}
312320

313321
move(_move: string, allowIncomplete = true) {
314-
assert(this.newTurn, "Cannot execute a move after executing an incomplete move");
322+
if (this.replay) {
323+
this.newTurn = true;
324+
} else {
325+
assert(this.newTurn, "Cannot execute a move after executing an incomplete move");
326+
}
315327

316328
const execute = () => {
317-
if (!this.executeMove(move)) {
318-
assert(allowIncomplete, `Move ${move} (line ${this.moveHistory.length + 1}) is not complete!`);
319-
this.newTurn = false;
329+
try {
330+
if (!this.executeMove(move)) {
331+
if (!this.replay) {
332+
assert(allowIncomplete, `Move ${move} (line ${this.moveHistory.length + 1}) is not complete!`);
333+
}
334+
this.newTurn = false;
335+
}
336+
} catch (e) {
337+
console.log(this.assertContext());
338+
throw e;
320339
}
321340
};
322341

@@ -329,7 +348,9 @@ export default class Engine {
329348
execute();
330349
}
331350

332-
assert(this.turnMoves.length === 0, "Unnecessary commands at the end of the turn: " + this.turnMoves.join(". "));
351+
if (!this.replay) {
352+
assert(this.turnMoves.length === 0, "Unnecessary commands at the end of the turn: " + this.turnMoves.join(". "));
353+
}
333354
this.moveHistory.push(moveToShow);
334355
}
335356

@@ -711,7 +732,7 @@ export default class Engine {
711732
return false;
712733
}
713734

714-
static fromData(data: Record<string, any>) {
735+
static fromData(data: Record<string, any>): Engine {
715736
const engine = new Engine();
716737
delete engine.version;
717738

@@ -836,10 +857,12 @@ export default class Engine {
836857
}
837858
}
838859

839-
assert(
840-
this.playerToMove === (player as PlayerEnum),
841-
"Wrong turn order in move " + move + ", expected player " + (this.playerToMove + 1) + this.assertContext()
842-
);
860+
if (!this.replay) {
861+
assert(
862+
this.playerToMove === (player as PlayerEnum),
863+
"Wrong turn order in move " + move + ", expected player " + (this.playerToMove + 1)
864+
);
865+
}
843866
this.processedPlayer = player;
844867

845868
const split = params.split ?? true;
@@ -896,7 +919,7 @@ export default class Engine {
896919
if (subphase) {
897920
this.generateAvailableCommands(subphase, data);
898921
if (this.availableCommands.length === 0) {
899-
if (required) {
922+
if (required && !this.replay) {
900923
// not allowed - see https://github.com/boardgamers/gaia-project/issues/76
901924
this.availableCommands = [{ name: Command.DeadEnd, player: this.currentPlayer, data: subphase }];
902925
} else {
@@ -927,10 +950,10 @@ export default class Engine {
927950
}
928951

929952
checkCommand(command: Command) {
930-
assert(
931-
(this.availableCommand = this.findAvailableCommand(this.playerToMove, command)),
932-
`Command ${command} is not in the list of available commands: ${this.assertContext()}`
933-
);
953+
this.availableCommand = this.findAvailableCommand(this.playerToMove, command);
954+
if (!this.availableCommand && !this.replay) {
955+
assert(this.availableCommand, `Command ${command} is not in the list of available commands`);
956+
}
934957
}
935958

936959
private assertContext(): string {
@@ -1525,10 +1548,12 @@ export default class Engine {
15251548
}
15261549

15271550
[Command.Bid](player: PlayerEnum, faction: string, bid: number) {
1528-
const bidsAC = this.avCommand<Command.Bid>().data.bids;
1529-
const bidAC = bidsAC.find((b) => b.faction === faction);
1530-
assert(bidAC.bid.includes(+bid), "You have to bid the right amount");
1531-
assert(bidAC, `${faction} is not in the available factions`);
1551+
if (!this.replay) {
1552+
const bidsAC = this.avCommand<Command.Bid>().data.bids;
1553+
const bidAC = bidsAC.find((b) => b.faction === faction);
1554+
assert(bidAC, `${faction} is not in the available factions`);
1555+
assert(bidAC.bid.includes(+bid), "You have to bid the right amount");
1556+
}
15321557
this.executeBid(player, faction, bid);
15331558
}
15341559

@@ -1746,7 +1771,7 @@ export default class Engine {
17461771
return false;
17471772
};
17481773

1749-
assert(isPossible(cost, income), `spend ${cost} for ${income} is not allowed: ${this.assertContext()}`);
1774+
assert(isPossible(cost, income), `spend ${cost} for ${income} is not allowed`);
17501775

17511776
pl.payCosts(cost, Command.Spend);
17521777
pl.gainRewards(income, Command.Spend);
@@ -1796,7 +1821,7 @@ export default class Engine {
17961821
[Command.FormFederation](player: PlayerEnum, hexes: string, federation: Federation) {
17971822
const pl = this.player(player);
17981823

1799-
const fedInfo = pl.checkAndGetFederationInfo(hexes, this.map, this.options.flexibleFederations);
1824+
const fedInfo = pl.checkAndGetFederationInfo(hexes, this.map, this.options.flexibleFederations, this.replay);
18001825

18011826
assert(fedInfo, `Impossible to form federation at ${hexes}`);
18021827
assert(

engine/src/player.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe("Player", () => {
1717
player.faction = Faction.Terrans;
1818
player.loadFaction(standard);
1919

20-
const { cost } = player.canBuild(Planet.Terra, Building.Mine, false, {
20+
const { cost } = player.canBuild(Planet.Terra, Building.Mine, false, false, {
2121
addedCost: [new Reward(1, Resource.Qic)],
2222
});
2323

engine/src/player.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export default class Player extends EventEmitter {
247247
targetPlanet: Planet,
248248
building: Building,
249249
lastRound: boolean,
250+
replay: boolean,
250251
{
251252
isolated,
252253
addedCost,
@@ -285,7 +286,7 @@ export default class Player extends EventEmitter {
285286
} else if (building === Building.Mine) {
286287
// habitability costs
287288
if (targetPlanet === Planet.Gaia) {
288-
if (this.data.temporaryStep > 0) {
289+
if (this.data.temporaryStep > 0 && !replay) {
289290
// not allowed - see https://github.com/boardgamers/gaia-project/issues/76
290291
// OR (for booster) there's no reason to activate the booster and not use it
291292
return null;
@@ -300,7 +301,7 @@ export default class Player extends EventEmitter {
300301
} else {
301302
// Get the number of terraforming steps to pay discounting terraforming track
302303
steps = terraformingStepsRequired(factionPlanet(this.faction), targetPlanet);
303-
const reward = terraformingCost(this.data, steps);
304+
const reward = terraformingCost(this.data, steps, replay);
304305

305306
if (reward === null) {
306307
return null;
@@ -1058,7 +1059,7 @@ export default class Player extends EventEmitter {
10581059
this.federationCache = null;
10591060
}
10601061

1061-
checkAndGetFederationInfo(location: string, map: SpaceMap, flexible: boolean): FederationInfo {
1062+
checkAndGetFederationInfo(location: string, map: SpaceMap, flexible: boolean, replay: boolean): FederationInfo {
10621063
const hexes = this.hexesForFederationLocation(location, map);
10631064

10641065
// Check if no forbidden square
@@ -1079,13 +1080,17 @@ export default class Player extends EventEmitter {
10791080

10801081
const info = this.federationInfo(hexes);
10811082

1082-
assert(info.newSatellites <= this.maxSatellites, "Federation requires too many satellites");
1083+
if (!replay) {
1084+
assert(info.newSatellites <= this.maxSatellites, "Federation requires too many satellites");
1085+
}
10831086

10841087
// Check if outclassed by available federations
10851088
const available = this.availableFederations(map, flexible);
10861089
const outclasser = available.find((fed) => isOutclassedBy(info, fed));
10871090

1088-
assert(!outclasser, "Federation is outclassed by other federation at " + (outclasser?.hexes ?? []).join(","));
1091+
if (!replay) {
1092+
assert(!outclasser, "Federation is outclassed by other federation at " + (outclasser?.hexes ?? []).join(","));
1093+
}
10891094

10901095
// Check if federation can be built with less satellites
10911096
if (!flexible) {

engine/src/tiles/boosters.spec.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ describe("boosters", () => {
137137
`);
138138

139139
expect(() => new Engine([...moves], { factionVariant: "more-balanced" })).to.throw(
140-
"Command endturn is not in the list of available commands: last command: firaks pass booster9 returning booster4, index: 10,\n" +
141-
' available: [{"name":"deadEnd","player":0,"data":"buildMineOrGaiaFormer"}]'
140+
"Command endturn is not in the list of available commands"
142141
);
143142
});
144143
});

engine/wrapper.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,10 @@ export function factions(engine: Engine) {
180180
return engine.players.map((pl) => pl.faction);
181181
}
182182

183-
export async function replay(engine: Engine) {
183+
export async function replay(engine: Engine): Promise<Engine> {
184184
const oldPlayers = engine.players;
185185

186-
engine = new Engine(engine.moveHistory, engine.options);
186+
engine = new Engine(engine.moveHistory, engine.options, engine.version ?? "1.0.0", true); //fall back to unknown version
187187

188188
assert(engine.newTurn, "Last move of the game is incomplete");
189189

0 commit comments

Comments
 (0)