Fix weather draft and deploy setup
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
cmds = ["npm run build"]
|
||||
|
||||
[start]
|
||||
cmd = "npx serve dist -l 80 -s"
|
||||
cmd = "serve dist -l 80 -s"
|
||||
|
||||
[healthcheck]
|
||||
cmd = "curl -f http://localhost:80/ || exit 1"
|
||||
|
||||
960
package-lock.json
generated
960
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,14 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "npx serve dist -l 80 -s",
|
||||
"start": "serve dist -l 80 -s",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"serve": "^14.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +1,453 @@
|
||||
import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
|
||||
import type {
|
||||
EnergySimulation,
|
||||
GameState,
|
||||
NodeKey,
|
||||
PlayerId,
|
||||
RootBurst,
|
||||
RoundAnimation,
|
||||
} from "./types";
|
||||
import { keyFor, parseKey, shuffleArray } from "./utils";
|
||||
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
||||
|
||||
function getColumnRegion(state: GameState, column: number) {
|
||||
const third = state.config.columns / 3;
|
||||
if (column < third) {
|
||||
return "left";
|
||||
}
|
||||
const third = state.config.columns / 3;
|
||||
if (column < third) {
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (column >= state.config.columns - third) {
|
||||
return "right";
|
||||
}
|
||||
if (column >= state.config.columns - third) {
|
||||
return "right";
|
||||
}
|
||||
|
||||
return "center";
|
||||
return "center";
|
||||
}
|
||||
|
||||
function getLeafCounts(state: GameState) {
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const counts = state.players.map(() => 0);
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const counts = state.players.map(() => 0);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
if (!(childrenMap.get(nodeKey)?.length)) {
|
||||
counts[node.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
if (!childrenMap.get(nodeKey)?.length) {
|
||||
counts[node.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function getColumnPresence(state: GameState, column: number) {
|
||||
const owners = new Set<PlayerId>();
|
||||
const owners = new Set<PlayerId>();
|
||||
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const ownerId = state.nodes.get(keyFor(row, column))?.ownerId;
|
||||
if (ownerId !== undefined) {
|
||||
owners.add(ownerId);
|
||||
}
|
||||
}
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const ownerId = state.nodes.get(keyFor(row, column))?.ownerId;
|
||||
if (ownerId !== undefined) {
|
||||
owners.add(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...owners];
|
||||
return [...owners];
|
||||
}
|
||||
|
||||
function addRoundedHalfBonus(scores: number[], counts: number[]) {
|
||||
counts.forEach((count, playerId) => {
|
||||
if (count > 0) {
|
||||
scores[playerId] += Math.ceil(count * 0.5);
|
||||
}
|
||||
});
|
||||
counts.forEach((count, playerId) => {
|
||||
if (count > 0) {
|
||||
scores[playerId] += Math.ceil(count * 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: PlayerId, playersPresent: PlayerId[]) {
|
||||
const contested = playersPresent.length > 1;
|
||||
function applyColumnBaseEnergy(
|
||||
state: GameState,
|
||||
scores: number[],
|
||||
ownerId: PlayerId,
|
||||
playersPresent: PlayerId[],
|
||||
) {
|
||||
const contested = playersPresent.length > 1;
|
||||
|
||||
if (!contested) {
|
||||
scores[ownerId] += 1;
|
||||
return;
|
||||
}
|
||||
if (!contested) {
|
||||
scores[ownerId] += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeRoundEffects.includes("stalemate")) {
|
||||
return;
|
||||
}
|
||||
if (state.activeRoundEffects.includes("stalemate")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeRoundEffects.includes("split_light")) {
|
||||
playersPresent.forEach((playerId) => {
|
||||
scores[playerId] += 0.5;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.activeRoundEffects.includes("split_light")) {
|
||||
playersPresent.forEach((playerId) => {
|
||||
scores[playerId] += 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scores[ownerId] += 1;
|
||||
scores[ownerId] += 1;
|
||||
}
|
||||
|
||||
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
|
||||
if (state.activeRoundEffects.length === 0) {
|
||||
return;
|
||||
}
|
||||
function applyWeatherEffects(
|
||||
state: GameState,
|
||||
scores: number[],
|
||||
energySimulation: EnergySimulation,
|
||||
) {
|
||||
if (state.activeRoundEffects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leafCounts = getLeafCounts(state);
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const tallestLeaves = state.players.map(() => null as number | null);
|
||||
const leafCounts = getLeafCounts(state);
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const tallestLeaves = state.players.map(() => null as number | null);
|
||||
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
const leafCount = leafCounts[node.ownerId];
|
||||
if (leafCount <= 0) {
|
||||
return;
|
||||
}
|
||||
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||
const leafCount = leafCounts[node.ownerId];
|
||||
if (leafCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (childrenMap.get(nodeKey)?.length) {
|
||||
return;
|
||||
}
|
||||
if (childrenMap.get(nodeKey)?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { row } = parseKey(nodeKey);
|
||||
const currentTallest = tallestLeaves[node.ownerId];
|
||||
if (currentTallest === null || row < currentTallest) {
|
||||
tallestLeaves[node.ownerId] = row;
|
||||
}
|
||||
});
|
||||
const { row } = parseKey(nodeKey);
|
||||
const currentTallest = tallestLeaves[node.ownerId];
|
||||
if (currentTallest === null || row < currentTallest) {
|
||||
tallestLeaves[node.ownerId] = row;
|
||||
}
|
||||
});
|
||||
|
||||
state.activeRoundEffects.forEach((effectId) => {
|
||||
if (effectId === "leaf_surge") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += count;
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.activeRoundEffects.forEach((effectId) => {
|
||||
if (effectId === "leaf_surge") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += count;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectId === "branching_season") {
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += Math.max(0, count - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (effectId === "branching_season") {
|
||||
// Find the player with the most leaves
|
||||
let maxLeaves = 0;
|
||||
let playerWithMostLeaves = -1;
|
||||
|
||||
if (effectId === "tall_reward") {
|
||||
const bestRow = tallestLeaves.reduce<number | null>((currentBest, row) => {
|
||||
if (row === null) {
|
||||
return currentBest;
|
||||
}
|
||||
leafCounts.forEach((count, playerId) => {
|
||||
scores[playerId] += Math.max(0, count - 1);
|
||||
if (count > maxLeaves) {
|
||||
maxLeaves = count;
|
||||
playerWithMostLeaves = playerId;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentBest === null || row < currentBest) {
|
||||
return row;
|
||||
}
|
||||
// Give the player with most leaves 50% more energy (rounded down)
|
||||
if (playerWithMostLeaves !== -1 && maxLeaves > 1) {
|
||||
const bonus = Math.floor((maxLeaves - 1) * 0.5);
|
||||
scores[playerWithMostLeaves] += bonus;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return currentBest;
|
||||
}, null);
|
||||
if (effectId === "tall_reward" || effectId === "deep_roots") {
|
||||
const bestRow = tallestLeaves.reduce<number | null>(
|
||||
(currentBest, row) => {
|
||||
if (row === null) {
|
||||
return currentBest;
|
||||
}
|
||||
|
||||
if (bestRow !== null) {
|
||||
tallestLeaves.forEach((row, playerId) => {
|
||||
if (row === bestRow) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(effectId === "deep_roots" &&
|
||||
(currentBest === null || row > currentBest)) ||
|
||||
(effectId === "tall_reward" &&
|
||||
(currentBest === null || row < currentBest))
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
|
||||
if (effectId === "wide_reach") {
|
||||
const maxScore = Math.max(...energySimulation.scores);
|
||||
energySimulation.scores.forEach((score, playerId) => {
|
||||
if (score === maxScore && maxScore > 0) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
return currentBest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const westLightCounts = state.players.map(() => 0);
|
||||
const eastLightCounts = state.players.map(() => 0);
|
||||
const highNoonCounts = state.players.map(() => 0);
|
||||
if (bestRow !== null) {
|
||||
tallestLeaves.forEach((row, playerId) => {
|
||||
if (row === bestRow) {
|
||||
scores[playerId] += effectId === "deep_roots" ? 4 : 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
energySimulation.columns.forEach((column) => {
|
||||
if (!column.intercepted || column.ownerId === null) {
|
||||
return;
|
||||
}
|
||||
if (effectId === "wide_reach") {
|
||||
const maxScore = Math.max(...energySimulation.scores);
|
||||
energySimulation.scores.forEach((score, playerId) => {
|
||||
if (score === maxScore && maxScore > 0) {
|
||||
scores[playerId] += 2;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const region = getColumnRegion(state, column.column);
|
||||
if (region === "left") {
|
||||
westLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "right") {
|
||||
eastLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "center") {
|
||||
highNoonCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
const westLightCounts = state.players.map(() => 0);
|
||||
const eastLightCounts = state.players.map(() => 0);
|
||||
const highNoonCounts = state.players.map(() => 0);
|
||||
|
||||
if (effectId === "west_light") {
|
||||
addRoundedHalfBonus(scores, westLightCounts);
|
||||
}
|
||||
if (effectId === "east_light") {
|
||||
addRoundedHalfBonus(scores, eastLightCounts);
|
||||
}
|
||||
if (effectId === "high_noon") {
|
||||
addRoundedHalfBonus(scores, highNoonCounts);
|
||||
}
|
||||
});
|
||||
energySimulation.columns.forEach((column) => {
|
||||
if (!column.intercepted || column.ownerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const region = getColumnRegion(state, column.column);
|
||||
if (region === "left") {
|
||||
westLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "right") {
|
||||
eastLightCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (region === "center") {
|
||||
highNoonCounts[column.ownerId] += 1;
|
||||
}
|
||||
if (
|
||||
effectId === "edge_bloom" &&
|
||||
(column.column === 0 || column.column === state.config.columns - 1)
|
||||
) {
|
||||
scores[column.ownerId] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (effectId === "west_light") {
|
||||
addRoundedHalfBonus(scores, westLightCounts);
|
||||
}
|
||||
if (effectId === "east_light") {
|
||||
addRoundedHalfBonus(scores, eastLightCounts);
|
||||
}
|
||||
if (effectId === "high_noon") {
|
||||
addRoundedHalfBonus(scores, highNoonCounts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
||||
const parentMap = buildParentMap(state);
|
||||
const columns = [];
|
||||
const scores = state.players.map(() => 0);
|
||||
const parentMap = buildParentMap(state);
|
||||
const columns = [];
|
||||
const scores = state.players.map(() => 0);
|
||||
|
||||
for (let column = 0; column < state.config.columns; column += 1) {
|
||||
let hitNodeKey: NodeKey | null = null;
|
||||
for (let column = 0; column < state.config.columns; column += 1) {
|
||||
let hitNodeKey: NodeKey | null = null;
|
||||
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const nodeKey = keyFor(row, column);
|
||||
if (state.nodes.has(nodeKey)) {
|
||||
hitNodeKey = nodeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let row = 0; row < state.config.rows; row += 1) {
|
||||
const nodeKey = keyFor(row, column);
|
||||
if (state.nodes.has(nodeKey)) {
|
||||
hitNodeKey = nodeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitNodeKey) {
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: state.config.rows - 1,
|
||||
intercepted: false,
|
||||
ownerId: null,
|
||||
playersPresent: [],
|
||||
hitNode: null,
|
||||
rootKey: null,
|
||||
branchNodes: [],
|
||||
branchEdges: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!hitNodeKey) {
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: state.config.rows - 1,
|
||||
intercepted: false,
|
||||
ownerId: null,
|
||||
playersPresent: [],
|
||||
hitNode: null,
|
||||
rootKey: null,
|
||||
branchNodes: [],
|
||||
branchEdges: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hitNode = parseKey(hitNodeKey);
|
||||
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
|
||||
const playersPresent = getColumnPresence(state, column);
|
||||
const branchNodes = [hitNode];
|
||||
const branchEdges = [];
|
||||
let cursor = hitNodeKey;
|
||||
const hitNode = parseKey(hitNodeKey);
|
||||
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
|
||||
const playersPresent = getColumnPresence(state, column);
|
||||
const branchNodes = [hitNode];
|
||||
const branchEdges = [];
|
||||
let cursor = hitNodeKey;
|
||||
|
||||
while (parentMap.has(cursor)) {
|
||||
const parentKey = parentMap.get(cursor) as NodeKey;
|
||||
branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
|
||||
branchNodes.push(parseKey(parentKey));
|
||||
cursor = parentKey;
|
||||
}
|
||||
while (parentMap.has(cursor)) {
|
||||
const parentKey = parentMap.get(cursor) as NodeKey;
|
||||
branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
|
||||
branchNodes.push(parseKey(parentKey));
|
||||
cursor = parentKey;
|
||||
}
|
||||
|
||||
applyColumnBaseEnergy(state, scores, ownerId, playersPresent);
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: hitNode.row,
|
||||
intercepted: true,
|
||||
ownerId,
|
||||
playersPresent,
|
||||
hitNode,
|
||||
rootKey: cursor,
|
||||
branchNodes,
|
||||
branchEdges,
|
||||
});
|
||||
}
|
||||
applyColumnBaseEnergy(state, scores, ownerId, playersPresent);
|
||||
columns.push({
|
||||
column,
|
||||
terminalRow: hitNode.row,
|
||||
intercepted: true,
|
||||
ownerId,
|
||||
playersPresent,
|
||||
hitNode,
|
||||
rootKey: cursor,
|
||||
branchNodes,
|
||||
branchEdges,
|
||||
});
|
||||
}
|
||||
|
||||
const rootBurstMap = columns.reduce((map, column) => {
|
||||
if (!column.intercepted || !column.rootKey) {
|
||||
return map;
|
||||
}
|
||||
const rootBurstMap = columns.reduce((map, column) => {
|
||||
if (!column.intercepted || !column.rootKey) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 };
|
||||
entry.count += 1;
|
||||
map.set(column.rootKey, entry);
|
||||
return map;
|
||||
}, new Map<NodeKey, RootBurst>());
|
||||
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||
const entry = map.get(column.rootKey) ?? {
|
||||
key: column.rootKey,
|
||||
playerId: column.ownerId as PlayerId,
|
||||
count: 0,
|
||||
displayCount: 0,
|
||||
};
|
||||
entry.count += 1;
|
||||
map.set(column.rootKey, entry);
|
||||
return map;
|
||||
}, new Map<NodeKey, RootBurst>());
|
||||
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||
|
||||
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||
|
||||
return {
|
||||
scores,
|
||||
columns,
|
||||
rootBursts,
|
||||
};
|
||||
const playerRootTotals = state.players.map(() => 0);
|
||||
rootBursts.forEach((burst) => {
|
||||
playerRootTotals[burst.playerId] += burst.count;
|
||||
});
|
||||
|
||||
const playerLargestBurst = new Map<PlayerId, RootBurst>();
|
||||
rootBursts.forEach((burst) => {
|
||||
const current = playerLargestBurst.get(burst.playerId);
|
||||
if (!current || burst.count > current.count) {
|
||||
playerLargestBurst.set(burst.playerId, burst);
|
||||
}
|
||||
});
|
||||
|
||||
rootBursts.forEach((burst) => {
|
||||
burst.displayCount = burst.count;
|
||||
});
|
||||
|
||||
state.players.forEach((player) => {
|
||||
const largestBurst = playerLargestBurst.get(player.id);
|
||||
if (!largestBurst) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extraEarned = scores[player.id] - playerRootTotals[player.id] + 1;
|
||||
largestBurst.displayCount += extraEarned;
|
||||
});
|
||||
|
||||
return {
|
||||
scores,
|
||||
columns,
|
||||
rootBursts,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoundAnimation(
|
||||
state: GameState,
|
||||
energySimulation: EnergySimulation,
|
||||
sunbeamPlayerId: PlayerId | null,
|
||||
diseaseKeys: NodeKey[],
|
||||
state: GameState,
|
||||
energySimulation: EnergySimulation,
|
||||
sunbeamPlayerId: PlayerId | null,
|
||||
diseaseKeys: NodeKey[],
|
||||
): RoundAnimation {
|
||||
const traces = energySimulation.columns
|
||||
.filter((column) => column.intercepted)
|
||||
.map((column) => ({
|
||||
playerId: column.ownerId as PlayerId,
|
||||
verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })),
|
||||
ray: {
|
||||
x: ((column.column + 0.5) / state.config.columns) * 100,
|
||||
y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
|
||||
},
|
||||
rootKey: column.rootKey,
|
||||
branchNodes: column.branchNodes,
|
||||
}));
|
||||
const traces = energySimulation.columns
|
||||
.filter((column) => column.intercepted)
|
||||
.map((column) => ({
|
||||
playerId: column.ownerId as PlayerId,
|
||||
verticalCells: Array.from(
|
||||
{ length: column.terminalRow + 1 },
|
||||
(_, row) => ({ row, column: column.column }),
|
||||
),
|
||||
ray: {
|
||||
x: ((column.column + 0.5) / state.config.columns) * 100,
|
||||
y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
|
||||
},
|
||||
rootKey: column.rootKey,
|
||||
branchNodes: column.branchNodes,
|
||||
}));
|
||||
|
||||
const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null;
|
||||
const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null;
|
||||
const bonusTrace =
|
||||
sunbeamPlayerId === null
|
||||
? null
|
||||
: (traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null);
|
||||
const bonusBurst = bonusTrace
|
||||
? (energySimulation.rootBursts.find(
|
||||
(burst) => burst.key === bonusTrace.rootKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
phase: "sunlight",
|
||||
columns: energySimulation.columns,
|
||||
traces,
|
||||
rootBursts: energySimulation.rootBursts,
|
||||
sunbeamPlayerId,
|
||||
bonusTrace,
|
||||
bonusBurst,
|
||||
diseaseKeys,
|
||||
};
|
||||
return {
|
||||
phase: "sunlight",
|
||||
columns: energySimulation.columns,
|
||||
traces,
|
||||
rootBursts: energySimulation.rootBursts,
|
||||
sunbeamPlayerId,
|
||||
bonusTrace,
|
||||
bonusBurst,
|
||||
diseaseKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreColumns(state: GameState) {
|
||||
const energySimulation = buildEnergySimulation(state);
|
||||
const columnResults = energySimulation.columns.map((column) => ({
|
||||
column: column.column,
|
||||
ownerId: column.ownerId,
|
||||
topRow: column.intercepted ? column.terminalRow : null,
|
||||
tied: false,
|
||||
}));
|
||||
const energySimulation = buildEnergySimulation(state);
|
||||
const columnResults = energySimulation.columns.map((column) => ({
|
||||
column: column.column,
|
||||
ownerId: column.ownerId,
|
||||
topRow: column.intercepted ? column.terminalRow : null,
|
||||
tied: false,
|
||||
}));
|
||||
|
||||
return { scores: energySimulation.scores, columnResults, energySimulation };
|
||||
return { scores: energySimulation.scores, columnResults, energySimulation };
|
||||
}
|
||||
|
||||
export function maybeRollSunbeam(state: GameState, scores: number[]) {
|
||||
const nextGrowth = scores.map((score) => score + 1);
|
||||
const { sunbeamChance } = state.randomEffects;
|
||||
const nextGrowth = scores.map((score) => score + 1);
|
||||
const { sunbeamChance } = state.randomEffects;
|
||||
|
||||
if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
|
||||
return {
|
||||
nextGrowth,
|
||||
event: null,
|
||||
awardedPlayer: null,
|
||||
};
|
||||
}
|
||||
if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
|
||||
return {
|
||||
nextGrowth,
|
||||
event: null,
|
||||
awardedPlayer: null,
|
||||
};
|
||||
}
|
||||
|
||||
const awardedPlayer = Math.floor(Math.random() * state.players.length);
|
||||
nextGrowth[awardedPlayer] += 1;
|
||||
const awardedPlayer = Math.floor(Math.random() * state.players.length);
|
||||
nextGrowth[awardedPlayer] += 1;
|
||||
|
||||
return {
|
||||
nextGrowth,
|
||||
awardedPlayer,
|
||||
event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
|
||||
};
|
||||
return {
|
||||
nextGrowth,
|
||||
awardedPlayer,
|
||||
event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeRollDisease(state: GameState) {
|
||||
const { diseaseChance } = state.randomEffects;
|
||||
if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
const { diseaseChance } = state.randomEffects;
|
||||
if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const parentMap = buildParentMap(state);
|
||||
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
|
||||
const { row } = parseKey(nodeKey);
|
||||
return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey);
|
||||
});
|
||||
const childrenMap = buildChildrenMap(state);
|
||||
const parentMap = buildParentMap(state);
|
||||
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
|
||||
const { row } = parseKey(nodeKey);
|
||||
return (
|
||||
row !== state.config.rows - 1 &&
|
||||
!childrenMap.has(nodeKey) &&
|
||||
parentMap.has(nodeKey)
|
||||
);
|
||||
});
|
||||
|
||||
if (twigKeys.length === 0) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
if (twigKeys.length === 0) {
|
||||
return {
|
||||
killedKeys: [],
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
|
||||
const shuffled = shuffleArray(twigKeys);
|
||||
const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)));
|
||||
const killedKeys = shuffled.slice(0, killCount);
|
||||
const shuffled = shuffleArray(twigKeys);
|
||||
const killCount = Math.min(
|
||||
shuffled.length,
|
||||
1 + Math.floor(Math.random() * Math.min(3, shuffled.length)),
|
||||
);
|
||||
const killedKeys = shuffled.slice(0, killCount);
|
||||
|
||||
return {
|
||||
killedKeys,
|
||||
event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
|
||||
};
|
||||
return {
|
||||
killedKeys,
|
||||
event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { shuffleArray } from "./utils";
|
||||
|
||||
export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
|
||||
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." },
|
||||
{ id: "storehouse", title: "Storehouse", description: "Banking is enabled this round." },
|
||||
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1. Player with most branches gets 50% more energy (rounded down)." },
|
||||
{ id: "storehouse", title: "Storehouse", description: "Banked energy is safe but earns no interest. Lose 1 banked energy (min 0)." },
|
||||
{ id: "compound_interest", title: "Compound Interest", description: "Gain 20% interest on all banked energy (rounded down)." },
|
||||
{ id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." },
|
||||
{ id: "west_light", title: "West Light", description: "Left third energy gets +50%, rounded up." },
|
||||
{ id: "east_light", title: "East Light", description: "Right third energy gets +50%, rounded up." },
|
||||
@@ -12,8 +13,9 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
|
||||
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
|
||||
{ id: "tall_reward", title: "Tall Reward", description: "Tallest leaf on the board gives +2." },
|
||||
{ id: "deep_roots", title: "Deep Roots", description: "Shortest plant receives +4 energy." },
|
||||
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
|
||||
{ id: "split_light", title: "Split Light", description: "Contested columns give half to each player there." },
|
||||
{ id: "split_light", title: "Split Light", description: "Contested columns give 1 energy to each player there." },
|
||||
];
|
||||
|
||||
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
|
||||
@@ -22,18 +24,27 @@ export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>
|
||||
|
||||
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
|
||||
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] },
|
||||
{ id: "tempo_tools", options: ["storehouse", "sun_ladder"] },
|
||||
{ id: "banking_mix", options: ["storehouse", "compound_interest"] },
|
||||
{ id: "tempo", options: ["sun_ladder", "edge_bloom"] },
|
||||
{ id: "side_bias", options: ["west_light", "east_light"] },
|
||||
{ id: "shape_bias", options: ["high_noon", "edge_bloom"] },
|
||||
{ id: "reward_shape", options: ["wide_reach", "tall_reward"] },
|
||||
{ id: "shape", options: ["high_noon", "wide_reach"] },
|
||||
{ id: "height", options: ["tall_reward", "deep_roots"] },
|
||||
{ id: "contest_soft", options: ["stalemate", "split_light"] },
|
||||
];
|
||||
|
||||
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||
const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount);
|
||||
|
||||
// Rotate draft order based on round number to ensure fairness
|
||||
const rotatedOrder = [...state.turnOrder];
|
||||
const rotation = (state.round - 1) % state.turnOrder.length;
|
||||
if (rotation > 0) {
|
||||
const start = rotatedOrder.splice(0, rotation);
|
||||
rotatedOrder.push(...start);
|
||||
}
|
||||
|
||||
return {
|
||||
playerOrder: [...state.turnOrder],
|
||||
playerOrder: rotatedOrder,
|
||||
draftIndex: 0,
|
||||
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
|
||||
drafted: [],
|
||||
|
||||
@@ -6,16 +6,47 @@ export function createDefaultPaletteOrder(playerCount: number) {
|
||||
return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length);
|
||||
}
|
||||
|
||||
function getMinimumColumnsForEvenSpacing(playerCount: number, minColumns: number): number {
|
||||
// We need columns to be divisible by (playerCount + 1) for even spacing
|
||||
// Each player needs at least 1 column, so minimum is playerCount
|
||||
// But for even spacing from edges, we need: columns % (playerCount + 1) === 0
|
||||
const spacingDivisor = playerCount + 1;
|
||||
|
||||
// Start from at least minColumns or playerCount (whichever is larger)
|
||||
let columns = Math.max(minColumns, playerCount);
|
||||
|
||||
// Increase columns until it's divisible by (playerCount + 1)
|
||||
while (columns % spacingDivisor !== 0) {
|
||||
columns += 1;
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
|
||||
const totalSeeds = playerCount * startingNodesPerPlayer;
|
||||
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
|
||||
// Calculate spacing to place players equidistant from each other and edges
|
||||
// Formula: space = columns / (playerCount + 1)
|
||||
// This ensures equal spacing between players and from edges
|
||||
const spacing = columns / (playerCount + 1);
|
||||
|
||||
return Array.from({ length: playerCount }, (_, playerId) => {
|
||||
const start = playerId * startingNodesPerPlayer;
|
||||
return positions
|
||||
.slice(start, start + startingNodesPerPlayer)
|
||||
.map((column) => String(column + 1))
|
||||
.join(", ");
|
||||
const playerCenter = Math.round((playerId + 1) * spacing);
|
||||
|
||||
// For multiple seeds per player, spread them around the center
|
||||
if (startingNodesPerPlayer === 1) {
|
||||
return String(playerCenter + 1);
|
||||
}
|
||||
|
||||
// For multiple seeds, alternate left and right from center
|
||||
const positions = [];
|
||||
for (let i = 0; i < startingNodesPerPlayer; i++) {
|
||||
// Alternate: 0, +1, -1, +2, -2, etc.
|
||||
const offset = i === 0 ? 0 : (i % 2 === 1 ? Math.ceil(i / 2) : -Math.ceil(i / 2));
|
||||
const pos = Math.max(1, Math.min(columns, playerCenter + offset + 1));
|
||||
positions.push(pos);
|
||||
}
|
||||
|
||||
return positions.join(", ");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,19 +111,23 @@ export function createSetupState(
|
||||
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
||||
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
||||
weatherDraftEnabled = true,
|
||||
weatherDraftCount = playerCount + 2,
|
||||
weatherDraftCount = playerCount,
|
||||
bankingEnabled = true,
|
||||
winCondition: SetupState["winCondition"] = "rounds",
|
||||
maxRounds = 12,
|
||||
topLeafTarget = 4,
|
||||
): SetupState {
|
||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
||||
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
||||
// Adjust columns to ensure even spacing between players and edges
|
||||
const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
|
||||
|
||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
|
||||
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
|
||||
const paletteDefaults = createDefaultPaletteOrder(playerCount);
|
||||
|
||||
return {
|
||||
playerCount,
|
||||
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
|
||||
columns,
|
||||
columns: adjustedColumns,
|
||||
rows,
|
||||
startingNodesPerPlayer: clampedSeeds,
|
||||
sunbeamChance,
|
||||
@@ -103,9 +138,10 @@ export function createSetupState(
|
||||
biddingOrderRule,
|
||||
weatherDraftEnabled,
|
||||
weatherDraftCount: Math.max(1, weatherDraftCount),
|
||||
bankingEnabled,
|
||||
winCondition,
|
||||
maxRounds,
|
||||
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
||||
topLeafTarget: Math.max(1, Math.min(adjustedColumns, topLeafTarget)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +229,7 @@ export function createInitialState(setup: SetupState): GameState {
|
||||
biddingOrderRule: setup.biddingOrderRule,
|
||||
weatherDraftEnabled: setup.weatherDraftEnabled,
|
||||
weatherDraftCount: setup.weatherDraftCount,
|
||||
bankingEnabled: setup.bankingEnabled,
|
||||
winCondition: setup.winCondition,
|
||||
maxRounds: setup.maxRounds,
|
||||
topLeafTarget: setup.topLeafTarget,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type SetupState = {
|
||||
biddingOrderRule: "rotating" | "lowest_growth_income";
|
||||
weatherDraftEnabled: boolean;
|
||||
weatherDraftCount: number;
|
||||
bankingEnabled: boolean;
|
||||
winCondition: "rounds" | "top_leaves";
|
||||
maxRounds: number;
|
||||
topLeafTarget: number;
|
||||
@@ -55,6 +56,7 @@ export type GameConfig = {
|
||||
biddingOrderRule: SetupState["biddingOrderRule"];
|
||||
weatherDraftEnabled: boolean;
|
||||
weatherDraftCount: number;
|
||||
bankingEnabled: boolean;
|
||||
winCondition: SetupState["winCondition"];
|
||||
maxRounds: number;
|
||||
topLeafTarget: number;
|
||||
@@ -124,6 +126,7 @@ export type RootBurst = {
|
||||
key: NodeKey;
|
||||
playerId: PlayerId;
|
||||
count: number;
|
||||
displayCount: number;
|
||||
};
|
||||
|
||||
export type EnergySimulation = {
|
||||
@@ -188,6 +191,7 @@ export type WeatherCardId =
|
||||
| "leaf_surge"
|
||||
| "branching_season"
|
||||
| "storehouse"
|
||||
| "compound_interest"
|
||||
| "sun_ladder"
|
||||
| "west_light"
|
||||
| "east_light"
|
||||
@@ -195,6 +199,7 @@ export type WeatherCardId =
|
||||
| "edge_bloom"
|
||||
| "wide_reach"
|
||||
| "tall_reward"
|
||||
| "deep_roots"
|
||||
| "stalemate"
|
||||
| "split_light";
|
||||
|
||||
|
||||
1508
src/main.js
1508
src/main.js
File diff suppressed because it is too large
Load Diff
282
src/main.ts
282
src/main.ts
@@ -68,6 +68,7 @@ let isNewGameModalOpen = false;
|
||||
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
||||
let setupTab: "board" | "rules" | "events" | "players" = "board";
|
||||
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
|
||||
let isDraftPanelDocked = false;
|
||||
|
||||
function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
||||
setup = createSetupState(
|
||||
@@ -84,12 +85,32 @@ function rebuildSetup(overrides: Partial<SetupState> = {}) {
|
||||
overrides.biddingOrderRule ?? setup.biddingOrderRule,
|
||||
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
|
||||
overrides.weatherDraftCount ?? setup.weatherDraftCount,
|
||||
overrides.bankingEnabled ?? setup.bankingEnabled,
|
||||
overrides.winCondition ?? setup.winCondition,
|
||||
overrides.maxRounds ?? setup.maxRounds,
|
||||
overrides.topLeafTarget ?? setup.topLeafTarget,
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => {
|
||||
switch (character) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case "\"":
|
||||
return """;
|
||||
case "'":
|
||||
return "'";
|
||||
default:
|
||||
return character;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getLiveExposureScores() {
|
||||
return buildEnergySimulation(state).scores;
|
||||
}
|
||||
@@ -142,6 +163,14 @@ function getCurrentPlayer() {
|
||||
return state.players[state.activePlayerId];
|
||||
}
|
||||
|
||||
function getTreeOpacity(playerId: number) {
|
||||
if (state.gameOver) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return playerId === state.activePlayerId ? 1 : 0.8;
|
||||
}
|
||||
|
||||
function getPlayerById(playerId: number) {
|
||||
return state.players.find((player) => player.id === playerId) ?? null;
|
||||
}
|
||||
@@ -152,18 +181,19 @@ function getOrderedPlayers(playerIds: number[]) {
|
||||
|
||||
function getTurnLabel() {
|
||||
if (state.phase === "initiative" && state.initiativeDraft) {
|
||||
return `${getCurrentPlayer().name} drafts initiative`;
|
||||
return `${escapeHtml(getCurrentPlayer().name)} drafts initiative`;
|
||||
}
|
||||
|
||||
if (state.phase === "weather" && state.weatherDraft) {
|
||||
return `${getCurrentPlayer().name} drafts weather`;
|
||||
return `${escapeHtml(getCurrentPlayer().name)} drafts weather`;
|
||||
}
|
||||
|
||||
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`;
|
||||
return state.gameOver ? "Game Over" : `${escapeHtml(getCurrentPlayer().name)}'s turn`;
|
||||
}
|
||||
|
||||
function isBankingEnabled() {
|
||||
return state.activeRoundEffects.includes("storehouse");
|
||||
// Banking is enabled if setup allows it OR if storehouse effect is active
|
||||
return state.config.bankingEnabled || state.activeRoundEffects.includes("storehouse");
|
||||
}
|
||||
|
||||
function awardGrowth(player: Player, amount: number) {
|
||||
@@ -393,6 +423,7 @@ function startWeatherDraft() {
|
||||
}
|
||||
|
||||
state.phase = "weather";
|
||||
isDraftPanelDocked = false;
|
||||
state.turnMoves = [];
|
||||
updateSelection(null);
|
||||
state.weatherDraft = createWeatherDraft(state);
|
||||
@@ -402,6 +433,7 @@ function startWeatherDraft() {
|
||||
|
||||
function startInitiativeDraft() {
|
||||
state.phase = "initiative";
|
||||
isDraftPanelDocked = false;
|
||||
state.turnMoves = [];
|
||||
updateSelection(null);
|
||||
state.initiativeDraft = createInitiativeDraft(state);
|
||||
@@ -516,26 +548,31 @@ function finalizeWeatherDraft() {
|
||||
|
||||
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
|
||||
const draft = state.weatherDraft;
|
||||
if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) {
|
||||
const offer = draft?.offers.find((entry) => entry.id === offerId);
|
||||
if (!draft || !offer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offer = draft.offers.find((entry) => entry.id === offerId);
|
||||
const otherCardId = offer?.options.find((option) => option !== cardId) ?? null;
|
||||
|
||||
const playerId = getCurrentWeatherPlayerId(draft);
|
||||
if (action === "draft") {
|
||||
if (!isWeatherCardAvailable(draft, offerId, cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = getWeatherCard(cardId);
|
||||
draft.drafted.push(cardId);
|
||||
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
||||
if (otherCardId && !draft.locked.includes(otherCardId)) {
|
||||
draft.locked.push(otherCardId);
|
||||
}
|
||||
} else {
|
||||
const card = getWeatherCard(cardId);
|
||||
draft.banned.push(cardId);
|
||||
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
|
||||
}
|
||||
|
||||
if (otherCardId && !draft.locked.includes(otherCardId)) {
|
||||
draft.locked.push(otherCardId);
|
||||
offer.options.forEach((option) => {
|
||||
if (!draft.banned.includes(option)) {
|
||||
draft.banned.push(option);
|
||||
}
|
||||
});
|
||||
state.history.unshift(`${state.players[playerId].name} banned both cards in an offer.`);
|
||||
}
|
||||
|
||||
if (draft.draftIndex >= draft.playerOrder.length - 1) {
|
||||
@@ -736,6 +773,22 @@ async function endRound() {
|
||||
player.roundScore = scores[index];
|
||||
player.totalScore += scores[index];
|
||||
player.bonusPoints = nextGrowth[index] - scores[index];
|
||||
|
||||
// Apply banking effects from weather cards
|
||||
const hasStorehouse = state.activeRoundEffects.includes("storehouse");
|
||||
const hasCompoundInterest = state.activeRoundEffects.includes("compound_interest");
|
||||
|
||||
if (hasStorehouse) {
|
||||
// Storehouse: Lose 1 banked energy (min 0)
|
||||
player.bankedPoints = Math.max(0, player.bankedPoints - 1);
|
||||
}
|
||||
|
||||
if (hasCompoundInterest) {
|
||||
// Compound Interest: Gain 20% interest (rounded down)
|
||||
const interest = Math.floor(player.bankedPoints * 0.2);
|
||||
player.bankedPoints += interest;
|
||||
}
|
||||
|
||||
player.growthPoints = player.bankedPoints;
|
||||
player.lifetimeGrowthIncome += nextGrowth[index];
|
||||
player.growthPoints += nextGrowth[index];
|
||||
@@ -914,7 +967,7 @@ function renderNewGameModal() {
|
||||
<button class="setup-tab${setupTab === "events" ? " setup-tab--active" : ""}" data-setup-tab="events"><span aria-hidden="true">☀</span><span>Events</span></button>
|
||||
<button class="setup-tab${setupTab === "players" ? " setup-tab--active" : ""}" data-setup-tab="players"><span aria-hidden="true">◉</span><span>Players</span></button>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="modal-body">
|
||||
${setupTab === "board" ? `
|
||||
<section class="setup-section">
|
||||
@@ -992,6 +1045,10 @@ function renderNewGameModal() {
|
||||
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
|
||||
</label>
|
||||
` : ""}
|
||||
<label class="setup-field setup-field--checkbox">
|
||||
<span class="setup-field__label">Enable Banking</span>
|
||||
<input id="banking-toggle" type="checkbox" ${setup.bankingEnabled ? "checked" : ""} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
` : ""}
|
||||
@@ -1021,7 +1078,7 @@ function renderNewGameModal() {
|
||||
<div class="player-row">
|
||||
<div class="player-row__info">
|
||||
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
|
||||
<input class="player-name-input" data-player-id="${index}" type="text" value="${currentPlayer.name}" aria-label="${currentPlayer.name} name" />
|
||||
<input class="player-name-input" data-player-id="${index}" type="text" value="${escapeHtml(currentPlayer.name)}" aria-label="${escapeHtml(currentPlayer.name)} name" />
|
||||
</div>
|
||||
<div class="player-row__actions">
|
||||
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||
@@ -1049,15 +1106,15 @@ function renderNewGameModal() {
|
||||
<div class="player-list">
|
||||
${previewPlayers.map((currentPlayer, index) => `
|
||||
<label class="setup-field">
|
||||
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
|
||||
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" />
|
||||
<span class="setup-field__label" style="color: ${currentPlayer.color};">${escapeHtml(currentPlayer.name)}</span>
|
||||
<input class="seed-input" data-player-id="${index}" type="text" value="${escapeHtml(setup.seedInputs[index] ?? "")}" placeholder="e.g. 2, 5" />
|
||||
</label>
|
||||
`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
` : ""}
|
||||
</div>
|
||||
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button class="ghost-button" id="cancel-new-game">Cancel</button>
|
||||
<button class="primary-button" id="start-new-game">Start New Game</button>
|
||||
@@ -1076,21 +1133,31 @@ function renderWeatherDraftModal() {
|
||||
const currentPlayer = getCurrentWeatherDraftPlayer();
|
||||
|
||||
return `
|
||||
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
||||
<section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
||||
<div class="panel__title-row">
|
||||
<div>
|
||||
<p class="eyebrow">Round ${state.round}</p>
|
||||
<h1 id="weather-title">Weather Draft</h1>
|
||||
</div>
|
||||
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
|
||||
</div>
|
||||
<div class="seed-editor">
|
||||
<p class="seed-help">${currentPlayer?.name ?? "A player"} can draft or ban 1 card. Offered in pairs.</p>
|
||||
<div class="weather-key" aria-label="Weather action key">
|
||||
<span><strong>☀ Draft</strong>: take that card for 1 round</span>
|
||||
<span><strong>✕ Ban</strong>: remove just that card</span>
|
||||
<div class="weather-draft-header">
|
||||
<p class="weather-draft-instructions">${escapeHtml(currentPlayer?.name ?? "A player")} can draft either card, or ban both cards in an offer.</p>
|
||||
<div class="weather-draft-actions">
|
||||
<span class="weather-draft-action"><strong>☀ Draft</strong> - take that card for 1 round</span>
|
||||
<span class="weather-draft-action"><strong>✕ Ban Both</strong> - remove both cards in that offer</span>
|
||||
</div>
|
||||
<div class="initiative-order-row">
|
||||
${getOrderedPlayers(draft.playerOrder).map((player, index) => `<span class="initiative-pill${index === draft.draftIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
|
||||
<div class="weather-draft-order">
|
||||
${getOrderedPlayers(draft.playerOrder).map((player, index) => {
|
||||
const isActive = index === draft.draftIndex;
|
||||
const isNext = index === (draft.draftIndex + 1) % draft.playerOrder.length;
|
||||
return `
|
||||
<div class="weather-draft-player${isActive ? ' weather-draft-player--active' : ''}" style="--player-color: ${player.color};">
|
||||
<span class="weather-draft-player__name">${escapeHtml(player.name)}</span>
|
||||
${isNext ? '<span class="weather-draft-player__label">next draft</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-grid">
|
||||
@@ -1100,7 +1167,8 @@ function renderWeatherDraftModal() {
|
||||
<article class="weather-card${resolved ? " weather-card--resolved" : ""}">
|
||||
<div>
|
||||
<p class="eyebrow">Offer</p>
|
||||
<div class="weather-pair">
|
||||
<div class="weather-offer-layout">
|
||||
<div class="weather-pair">
|
||||
${offer.options.map((cardId, optionIndex) => {
|
||||
const card = getWeatherCard(cardId);
|
||||
const drafted = draft.drafted.includes(cardId);
|
||||
@@ -1117,15 +1185,13 @@ function renderWeatherDraftModal() {
|
||||
<span class="weather-action__icon" aria-hidden="true">☀</span>
|
||||
<span><strong>Draft</strong></span>
|
||||
</button>
|
||||
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
|
||||
<span class="weather-action__icon" aria-hidden="true">✕</span>
|
||||
<span><strong>Ban</strong></span>
|
||||
</button>
|
||||
</div>
|
||||
` : `<p class="weather-card__status">${drafted ? "Drafted" : banned ? "Banned" : "Locked"}</p>`}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
${!resolved ? `<button class="weather-action weather-action--ban-both" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${offer.options[0]}"><span class="weather-action__icon" aria-hidden="true">✕</span><span><strong>Ban Both</strong></span></button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1147,22 +1213,23 @@ function renderInitiativeModal() {
|
||||
const initiativeBonusStatus = getInitiativeBonusStatus();
|
||||
|
||||
return `
|
||||
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
||||
<section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
||||
<div class="panel__title-row">
|
||||
<div>
|
||||
<p class="eyebrow">Round ${state.round}</p>
|
||||
<h1 id="initiative-title">Initiative Draft</h1>
|
||||
</div>
|
||||
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
|
||||
</div>
|
||||
<div class="seed-editor">
|
||||
<p class="seed-help">${currentBidder?.name ?? "A player"} chooses a seat. Earlier seats gain extra growth before the round begins.</p>
|
||||
<p class="seed-help">${escapeHtml(currentBidder?.name ?? "A player")} chooses a seat. Earlier seats gain extra growth before the round begins.</p>
|
||||
<p class="initiative-bonus-note ${initiativeBonusStatus.bonusActive ? "initiative-bonus-note--active" : ""}">
|
||||
${initiativeBonusStatus.bonusActive
|
||||
? "Seat bonuses are active: Seat 1 gains +1 growth this round."
|
||||
: `Seat bonuses start in ${initiativeBonusStatus.roundsRemaining} round${initiativeBonusStatus.roundsRemaining === 1 ? "" : "s"}.`}
|
||||
</p>
|
||||
<div class="initiative-order-row">
|
||||
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
|
||||
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${escapeHtml(player.name)}</span>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="initiative-seat-grid">
|
||||
@@ -1174,7 +1241,7 @@ function renderInitiativeModal() {
|
||||
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
|
||||
<strong>Seat ${seatIndex + 1}</strong>
|
||||
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
|
||||
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
|
||||
<span>${assignedPlayer ? escapeHtml(assignedPlayer.name) : "Open"}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("")}
|
||||
@@ -1197,7 +1264,7 @@ function renderScoreboard() {
|
||||
<div class="score-card__head">
|
||||
<div class="score-card__identity">
|
||||
<span class="player-dot"></span>
|
||||
<h2>${player.name}</h2>
|
||||
<h2>${escapeHtml(player.name)}</h2>
|
||||
</div>
|
||||
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
|
||||
</div>
|
||||
@@ -1208,7 +1275,7 @@ function renderScoreboard() {
|
||||
</div>
|
||||
<div>
|
||||
<span>Energy</span>
|
||||
<strong class="${growthChanged ? "score-value changed" : "score-value"}">${player.growthPoints}</strong>
|
||||
<strong class="${growthChanged ? "score-value changed" : "score-value"}" data-energy-score="${player.id}">${player.growthPoints}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Bank</span>
|
||||
@@ -1262,14 +1329,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
||||
});
|
||||
}).join("") : "";
|
||||
|
||||
const roots = state.animation.rootBursts.map((burst) => {
|
||||
const player = state.players[burst.playerId];
|
||||
const root = parseKey(burst.key);
|
||||
const x = ((root.column + 0.5) / columns) * 100;
|
||||
const y = ((root.row + 0.5) / rows) * 100;
|
||||
return `<g class="board__root-burst" style="--trace-delay: 300ms; --burst-color: ${player.color};" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+${burst.count}</text></g>`;
|
||||
}).join("");
|
||||
|
||||
const disease = state.animation.diseaseKeys.map((key, index) => {
|
||||
const node = parseKey(key);
|
||||
const x = ((node.column + 0.5) / columns) * 100;
|
||||
@@ -1298,15 +1357,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
||||
return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
||||
}).join("");
|
||||
|
||||
const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined
|
||||
? ""
|
||||
: (() => {
|
||||
const root = parseKey(state.animation?.bonusBurst?.key as string);
|
||||
const x = ((root.column + 0.5) / columns) * 100;
|
||||
const y = ((root.row + 0.5) / rows) * 100;
|
||||
return `<g class="board__sunbeam-burst" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+1</text></g>`;
|
||||
})();
|
||||
|
||||
return `
|
||||
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
|
||||
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
|
||||
@@ -1317,13 +1367,103 @@ function renderAnimationOverlay(columns: number, rows: number) {
|
||||
${state.animation.phase === "bonus" ? bonusFlashes : ""}
|
||||
</div>
|
||||
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
${roots}
|
||||
${disease}
|
||||
${sunbeam}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderScoreFlightOverlay() {
|
||||
if (!state.animation || (state.animation.phase !== "branches" && state.animation.phase !== "bonus")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rootBursts = state.animation.phase === "branches"
|
||||
? state.animation.rootBursts.map((burst, index) => `
|
||||
<div
|
||||
class="score-flight-badge"
|
||||
data-root-key="${burst.key}"
|
||||
data-player-id="${burst.playerId}"
|
||||
data-flight-order="${index}"
|
||||
style="--burst-color: ${state.players[burst.playerId].color};"
|
||||
>
|
||||
+${burst.displayCount}
|
||||
</div>
|
||||
`).join("")
|
||||
: "";
|
||||
|
||||
const bonusBurst = state.animation.phase === "bonus" && state.animation.bonusBurst
|
||||
? `
|
||||
<div
|
||||
class="score-flight-badge score-flight-badge--bonus"
|
||||
data-root-key="${state.animation.bonusBurst.key}"
|
||||
data-player-id="${state.animation.bonusBurst.playerId}"
|
||||
data-flight-order="${state.animation.rootBursts.length}"
|
||||
style="--burst-color: #ffd85e;"
|
||||
>
|
||||
+1
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<div class="score-flight-layer" aria-hidden="true">${rootBursts}${bonusBurst}</div>`;
|
||||
}
|
||||
|
||||
function pulseEnergyScore(playerId: number, delayMs: number) {
|
||||
window.setTimeout(() => {
|
||||
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
|
||||
if (!targetValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetValue.classList.remove("score-value--landing");
|
||||
void targetValue.offsetWidth;
|
||||
targetValue.classList.add("score-value--landing");
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function positionScoreFlightBadges() {
|
||||
const flightLayer = document.querySelector<HTMLElement>(".score-flight-layer");
|
||||
if (!flightLayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layerRect = flightLayer.getBoundingClientRect();
|
||||
const badges = Array.from(flightLayer.querySelectorAll<HTMLElement>(".score-flight-badge"));
|
||||
|
||||
badges.forEach((badge, index) => {
|
||||
const rootKey = badge.dataset.rootKey;
|
||||
const playerId = Number(badge.dataset.playerId);
|
||||
if (!rootKey || Number.isNaN(playerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { row, column } = parseKey(rootKey);
|
||||
const sourceCell = document.querySelector<HTMLElement>(`.cell[data-row="${row}"][data-column="${column}"]`);
|
||||
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
|
||||
if (!sourceCell || !targetValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRect = sourceCell.getBoundingClientRect();
|
||||
const targetRect = targetValue.getBoundingClientRect();
|
||||
const startX = sourceRect.left + sourceRect.width / 2;
|
||||
const startY = sourceRect.top + sourceRect.height / 2;
|
||||
const endX = targetRect.left + targetRect.width / 2;
|
||||
const endY = targetRect.top + targetRect.height / 2;
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
|
||||
badge.style.left = `${startX}px`;
|
||||
badge.style.top = `${startY}px`;
|
||||
badge.style.setProperty("--flight-x", `${deltaX}px`);
|
||||
badge.style.setProperty("--flight-y", `${deltaY}px`);
|
||||
badge.style.setProperty("--flight-mid-x", `${deltaX * 0.55}px`);
|
||||
badge.style.setProperty("--flight-mid-y", `${deltaY - 42}px`);
|
||||
badge.style.setProperty("--flight-delay", `${index * 260}ms`);
|
||||
pulseEnergyScore(playerId, index * 260 + 2200);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const columns = state.config.columns;
|
||||
const rows = state.config.rows;
|
||||
@@ -1331,12 +1471,13 @@ function renderBoard() {
|
||||
const parentMap = buildParentMap();
|
||||
const lines = state.edges.map((edge) => {
|
||||
const player = state.players[edge.ownerId];
|
||||
const opacity = getTreeOpacity(edge.ownerId);
|
||||
const x1 = ((edge.from.column + 0.5) / columns) * 100;
|
||||
const y1 = ((edge.from.row + 0.5) / rows) * 100;
|
||||
const x2 = ((edge.to.column + 0.5) / columns) * 100;
|
||||
const y2 = ((edge.to.row + 0.5) / rows) * 100;
|
||||
|
||||
return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-width="0.9" stroke-linecap="round" />`;
|
||||
return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-opacity="${opacity}" stroke-width="0.9" stroke-linecap="round" />`;
|
||||
}).join("");
|
||||
|
||||
const cells = Array.from({ length: rows }, (_, row) => {
|
||||
@@ -1351,13 +1492,14 @@ function renderBoard() {
|
||||
const background = columnLeader.ownerId === null || columnLeader.tied
|
||||
? "transparent"
|
||||
: tint(state.players[columnLeader.ownerId].color);
|
||||
const treeOpacity = player ? getTreeOpacity(player.id) : 1;
|
||||
|
||||
return `
|
||||
<button
|
||||
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
|
||||
data-row="${row}"
|
||||
data-column="${column}"
|
||||
style="--column-tint: ${background}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
|
||||
style="--column-tint: ${background}; --tree-opacity: ${treeOpacity}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
|
||||
${isInteractionLocked() ? "disabled" : ""}
|
||||
>
|
||||
<span class="cell__shade"></span>
|
||||
@@ -1386,18 +1528,18 @@ function renderSidebar() {
|
||||
const player = getCurrentPlayer();
|
||||
const rootShiftMoves = getSelectedRootShiftMoves();
|
||||
const boardLocked = isInteractionLocked();
|
||||
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${orderedPlayer.name}`).join(" | ");
|
||||
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${escapeHtml(orderedPlayer.name)}`).join(" | ");
|
||||
const activeEffectsMarkup = state.activeRoundEffects.length > 0
|
||||
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
|
||||
const card = getWeatherCard(cardId);
|
||||
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span><span class="effect-chip__rule">${card?.description ?? ""}</span></div>`;
|
||||
}).join("")}</div>`
|
||||
: `<p class="effect-empty">No weather effects active.</p>`;
|
||||
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${state.players[index].name}: ${score}`).join(" | ");
|
||||
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${escapeHtml(state.players[index].name)}: ${score}`).join(" | ");
|
||||
const phaseHint = state.phase === "initiative"
|
||||
? "Choose a seat for this round."
|
||||
: state.phase === "weather"
|
||||
? "Draft one card or ban one card."
|
||||
? "Draft one card or ban both cards in an offer."
|
||||
: state.gameOver
|
||||
? "Final totals are locked."
|
||||
: `${player.growthPoints} energy. Click a selected pending node again to undo.`;
|
||||
@@ -1440,7 +1582,7 @@ function renderSidebar() {
|
||||
<p>Vertical growth costs 1. Diagonal growth costs 2.</p>
|
||||
<p>Click a selected pending node again to undo back through it.</p>
|
||||
${state.round === 1 ? `<p>Roots can shift left or right for ${ROOT_SHIFT_COST} during round 1.</p>` : ""}
|
||||
${isBankingEnabled() ? `<p>Storehouse is active, so banking is enabled this round.</p>` : `<p>Banking is disabled unless Storehouse is active.</p>`}
|
||||
${isBankingEnabled() ? `<p>Banking is enabled. Bank your remaining energy to save it for next round.</p>` : `<p>Banking is disabled in this game.</p>`}
|
||||
</div>
|
||||
</details>
|
||||
<details class="accordion">
|
||||
@@ -1465,7 +1607,7 @@ function renderSidebar() {
|
||||
<details class="accordion">
|
||||
<summary>Round Log</summary>
|
||||
<div class="accordion__content log-list">
|
||||
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
||||
${state.history.slice(0, 8).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
@@ -1528,6 +1670,10 @@ function attachEvents() {
|
||||
document.querySelector("#end-turn")?.addEventListener("click", endTurn);
|
||||
document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
|
||||
document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
|
||||
document.querySelector("#draft-panel-toggle")?.addEventListener("click", () => {
|
||||
isDraftPanelDocked = !isDraftPanelDocked;
|
||||
render();
|
||||
});
|
||||
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
|
||||
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
|
||||
render();
|
||||
@@ -1582,6 +1728,10 @@ function attachEvents() {
|
||||
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
|
||||
render();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#banking-toggle")?.addEventListener("change", (event) => {
|
||||
setup.bankingEnabled = (event.currentTarget as HTMLInputElement).checked;
|
||||
render();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
|
||||
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
|
||||
render();
|
||||
@@ -1679,11 +1829,15 @@ function render() {
|
||||
${renderScoreboard()}
|
||||
</footer>
|
||||
</main>
|
||||
${renderScoreFlightOverlay()}
|
||||
${renderNewGameModal()}
|
||||
${renderInitiativeModal()}
|
||||
${renderWeatherDraftModal()}
|
||||
`;
|
||||
attachEvents();
|
||||
requestAnimationFrame(() => {
|
||||
positionScoreFlightBadges();
|
||||
});
|
||||
previousScoreSnapshot = getScoreSnapshot();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user