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
7 changes: 7 additions & 0 deletions src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export class HostLobbyModal extends BaseModal {
});
}

/** Render the host lobby body: game config settings and the player list. */
protected renderBody() {
const inputCards = [
html`<toggle-input-card
Expand Down Expand Up @@ -323,6 +324,9 @@ export class HostLobbyModal extends BaseModal {
defaultValue: this.defaultNationCount,
labelKey: "host_modal.nations",
disabledKey: "host_modal.nations_disabled",
// Random lobby: show "Map default" without the count, which
// would otherwise reveal the (hidden) map.
hideDefaultValue: this.useRandomMap,
},
toggles: [
{
Expand Down Expand Up @@ -409,6 +413,7 @@ export class HostLobbyModal extends BaseModal {
.currentClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.nationCount=${this.nations}
.randomMap=${this.useRandomMap}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-player-view>
</div>
Expand Down Expand Up @@ -934,6 +939,7 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
}

/** Push the host's current lobby settings to the server as a config update. */
private async putGameConfig() {
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
? this.spawnImmunityDurationMinutes * 60 * 10
Expand All @@ -945,6 +951,7 @@ export class HostLobbyModal extends BaseModal {
detail: {
config: {
gameMap: this.selectedMap,
randomMap: this.useRandomMap,
gameMapSize: this.compactMap
? GameMapSize.Compact
: GameMapSize.Normal,
Expand Down
15 changes: 11 additions & 4 deletions src/client/JoinLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class JoinLobbyModal extends BaseModal {
});
}

/** Render the join lobby body: game config preview and the player list. */
protected renderBody() {
// Pre-join state: show lobby ID input form
if (!this.currentLobbyId) {
Expand Down Expand Up @@ -156,6 +157,7 @@ export class JoinLobbyModal extends BaseModal {
this.gameConfig?.nations ?? "default",
this.nationCount,
)}
.randomMap=${this.gameConfig?.randomMap === true}
></lobby-player-view>
`
: ""}
Expand Down Expand Up @@ -406,15 +408,20 @@ export class JoinLobbyModal extends BaseModal {

// --- Game config rendering ---

/** Render the lobby's map, mode, and option cards (map hidden when random). */
private renderGameConfig(): TemplateResult {
if (!this.gameConfig) return html``;

const c = this.gameConfig;
const mapName = getMapName(c.gameMap);
// A "Random" lobby keeps the concrete map hidden until the game starts.
const isRandomMap = c.randomMap === true;
const mapName = isRandomMap
? translateText("map.random")
: getMapName(c.gameMap);
const normalizedMap = normaliseMapKey(c.gameMap);
const thumbnailUrl = assetUrl(
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
);
const thumbnailUrl = isRandomMap
? assetUrl("images/RandomMap.webp")
: assetUrl(`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`);
const isTeam = c.gameMode === GameMode.Team;

let modeSubtitle: string;
Expand Down
4 changes: 4 additions & 0 deletions src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export class SinglePlayerModal extends BaseModal {
});
}

/** Render the single-player setup body: game config settings. */
protected renderBody() {
const inputCards = [
html`<toggle-input-card
Expand Down Expand Up @@ -290,6 +291,9 @@ export class SinglePlayerModal extends BaseModal {
defaultValue: this.defaultNationCount,
labelKey: "single_modal.nations",
disabledKey: "single_modal.nations_disabled",
// Random map: show "Map default" without the count, which
// would otherwise reveal the hidden map.
hideDefaultValue: this.useRandomMap,
},
toggles: [
{
Expand Down
54 changes: 48 additions & 6 deletions src/client/components/FluentSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export class FluentSlider extends LitElement {
@property({ type: String }) disabledKey = "";
@property({ type: Number }) defaultValue: number | undefined = undefined;
@property({ type: String }) defaultLabelKey = "";
/**
* When true, render only the default label (not the numeric value) while the
* slider sits at its default position. Used to hide the map's nation count
* for "Random" lobbies, where the number would reveal the map.
*/
@property({ type: Boolean }) hideDefaultValue = false;

@state() private isEditing = false;

Expand All @@ -31,6 +37,14 @@ export class FluentSlider extends LitElement {
);
}

/** Restore the slider to its default value and notify listeners. */
private resetToDefault() {
if (this.defaultValue === undefined) return;
this.value = this.defaultValue;
this.isEditing = false;
this.dispatchValueChange();
}

private handleSliderInput(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.valueAsNumber;
Expand Down Expand Up @@ -71,11 +85,23 @@ export class FluentSlider extends LitElement {
this.updateComplete.then(() => this.numberInput?.focus());
}

/** Render the range input, its value label, and the optional reset button. */
render() {
// For a hidden default (a "Random" lobby), the real value would reveal the
// map's nation count via the thumb's *position*, not just the number. While
// untouched at the default, park the thumb at the track's center so it
// gives nothing away. Dragging/typing still reports the real input value.
const atHiddenDefault =
this.hideDefaultValue &&
this.defaultValue !== undefined &&
this.value === this.defaultValue;
const displayValue = atHiddenDefault
? Math.round((this.min + this.max) / 2)
: this.value;
const percentage =
this.max === this.min
? 0
: ((this.value - this.min) / (this.max - this.min)) * 100;
: ((displayValue - this.min) / (this.max - this.min)) * 100;
return html`
<div
class="flex flex-col items-center justify-center gap-1 w-full text-center"
Expand All @@ -85,7 +111,7 @@ export class FluentSlider extends LitElement {
.min=${this.min}
.max=${this.max}
.step=${this.step}
.valueAsNumber=${this.value}
.valueAsNumber=${displayValue}
style="background: linear-gradient(to right, var(--color-malibu-blue) 0%, var(--color-malibu-blue) ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
class="w-full h-6 p-0 m-0 bg-transparent appearance-none cursor-pointer focus:outline-none
[&::-webkit-slider-runnable-track]:w-full [&::-webkit-slider-runnable-track]:h-[6px] [&::-webkit-slider-runnable-track]:cursor-pointer [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:transition-colors
Expand All @@ -107,7 +133,7 @@ export class FluentSlider extends LitElement {
type="number"
.min=${this.min}
.max=${this.max}
.valueAsNumber=${this.value}
.valueAsNumber=${displayValue}
class="w-[60px] bg-black/60 text-white border border-white/20 text-center rounded text-sm p-1 leading-none font-bold font-inherit mt-1 focus:outline-none focus:border-blue-500"
@input=${this.handleNumberInput}
@blur=${() => {
Expand Down Expand Up @@ -136,12 +162,28 @@ export class FluentSlider extends LitElement {
: this.defaultValue !== undefined &&
this.value === this.defaultValue &&
this.defaultLabelKey
? html`${this.value}
<span class="text-white/40 uppercase"
>(${translateText(this.defaultLabelKey)})</span
? this.hideDefaultValue
? html`<span class="text-white/40 uppercase"
>${translateText(this.defaultLabelKey)}</span
>`
: html`${this.value}
<span class="text-white/40 uppercase"
>(${translateText(this.defaultLabelKey)})</span
>`
: this.value}
</span>`}
${this.hideDefaultValue &&
this.defaultValue !== undefined &&
this.value !== this.defaultValue &&
this.defaultLabelKey
? html`<button
type="button"
class="text-xs lowercase text-white/40 hover:text-white underline underline-offset-2 mt-1 cursor-pointer bg-transparent border-none"
@click=${this.resetToDefault}
>
↺ ${translateText(this.defaultLabelKey)}
</button>`
: ""}
</div>
</div>
`;
Expand Down
4 changes: 4 additions & 0 deletions src/client/components/GameConfigSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export interface GameConfigSettingsData {
labelKey: string;
disabledKey: string;
hidden?: boolean;
hideDefaultValue?: boolean;
};
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
Expand Down Expand Up @@ -309,6 +310,7 @@ export class GameConfigSettings extends LitElement {
});
}

/** Render the map picker, difficulty, sliders, and option toggles. */
render() {
if (!this.settings) return nothing;
const settings = this.settings;
Expand Down Expand Up @@ -468,6 +470,8 @@ export class GameConfigSettings extends LitElement {
.value=${settings.options.nations.value}
.defaultValue=${settings.options.nations.defaultValue}
defaultLabelKey="common.map_default"
.hideDefaultValue=${settings.options.nations
.hideDefaultValue ?? false}
labelKey=${settings.options.nations.labelKey}
disabledKey=${settings.options.nations.disabledKey}
@value-changed=${this.handleNationsChanged}
Expand Down
25 changes: 20 additions & 5 deletions src/client/components/LobbyPlayerView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export class LobbyTeamView extends LitElement {
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Number }) nationCount: number = 0;
@property({ type: Boolean }) isPublicGame: boolean = false;
/** When true (a "Random" lobby), hide the nation count so it can't reveal the map. */
@property({ type: Boolean }) randomMap: boolean = false;

private get theme(): Theme {
return themeProvider.current();
Expand All @@ -50,12 +52,20 @@ export class LobbyTeamView extends LitElement {
* controls the nation count via the slider.
*/
private get effectiveNationCount(): number {
// A "Random" lobby must not reveal the map's nation count — not in the
// header, and not via anything derived from it (team-card totals, team
// sizing, player totals). Treat it as 0 everywhere; the header shows
// "Map default" via its own randomMap branch instead.
if (this.randomMap) {
return 0;
}
if (this.isPublicGame && this.teamCount === HumansVsNations) {
return this.clients.length;
}
return this.nationCount;
}

/** Recompute the team preview when an input affecting team layout changes. */
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
// clients is updated from WebSocket lobby_info events
Expand All @@ -64,14 +74,16 @@ export class LobbyTeamView extends LitElement {
changedProperties.has("clients") ||
changedProperties.has("teamCount") ||
changedProperties.has("nationCount") ||
changedProperties.has("isPublicGame")
changedProperties.has("isPublicGame") ||
changedProperties.has("randomMap")
) {
const teamsList = this.getTeamList();
this.computeTeamPreview(teamsList);
this.showTeamColors = teamsList.length <= 7;
}
}

/** Render the player/nation summary header and the team or FFA roster. */
render() {
return html`
<div class="border-t border-white/10 pt-6">
Expand All @@ -84,10 +96,13 @@ export class LobbyTeamView extends LitElement {
? translateText("host_modal.player")
: translateText("host_modal.players")}
<span style="margin: 0 8px;">•</span>
${this.effectiveNationCount}
${this.effectiveNationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")}
${this.randomMap
? html`${translateText("common.map_default")}
${translateText("host_modal.nation_players")}`
: html`${this.effectiveNationCount}
${this.effectiveNationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")}`}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>
<div
Expand Down
5 changes: 5 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;

export const GameConfigSchema = z.object({
gameMap: z.enum(GameMapType),
// When true, the host chose "Random": the concrete gameMap above is still
// resolved client-side (so nation count etc. work), but it must be hidden
// from all pre-start visuals (embed, lobby map name, nation count). The
// server clears this flag at prestart, which reveals the map.
randomMap: z.boolean().optional(),
difficulty: z.enum(Difficulty),
donateGold: z.boolean(), // Configures donations to humans only
donateTroops: z.boolean(), // Configures donations to humans only
Expand Down
24 changes: 19 additions & 5 deletions src/server/GamePreviewBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export function escapeHtml(value: string): string {
.replace(/'/g, "&#39;");
}

/**
* Build the OpenGraph/social preview (title, description, image, join URL) for
* a game link from its live lobby and/or archived public info. A pre-start
* "Random" lobby is shown with a generic placeholder so the map isn't spoiled.
*/
export async function buildPreview(
gameID: string,
origin: string,
Expand Down Expand Up @@ -162,6 +167,10 @@ export async function buildPreview(
countActivePlayers(players) || (lobby?.clients?.length ?? 0);
}
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
// While a "Random" lobby hasn't started, hide the concrete map so the embed
// doesn't spoil it. The server clears randomMap at prestart, so an
// in-progress/finished game reveals its real map normally.
const isRandomMap = lobby?.gameConfig?.randomMap === true && !isFinished;
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
const playerTeams = lobby?.gameConfig?.playerTeams ?? config.playerTeams;
const numericTeamCount =
Expand Down Expand Up @@ -192,23 +201,28 @@ export async function buildPreview(
const duration = publicInfo?.info?.duration;

// Normalize map name to match filesystem (lowercase, no spaces or special chars)
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
const normalizedMap =
map && !isRandomMap ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;

const mapThumbnail = normalizedMap
? buildAbsoluteAssetUrl(
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
)
: null;
const image =
mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png");
const image = isRandomMap
? buildAbsoluteAssetUrl("images/RandomMap.webp")
: (mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png"));

// The name shown in the title: "Random Map" until the lobby starts.
const displayMap = isRandomMap ? "Random Map" : map;

const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
const gameTypeLabel = gameType ? ` (${gameType})` : "";

const title = isFinished
? `${mode ?? "Game"} on ${map ?? "Unknown Map"}${gameTypeLabel}`
: mode && map
? `${mode} on ${map}${gameTypeLabel}`
: mode && displayMap
? `${mode} on ${displayMap}${gameTypeLabel}`
: "OpenFront Game";

let description: string;
Expand Down
11 changes: 11 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,14 @@ export class GameServer {
: undefined;
}

/** Merge an allowed subset of config fields from a lobby host's update. */
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
if (gameConfig.randomMap !== undefined) {
this.gameConfig.randomMap = gameConfig.randomMap;
}
if (gameConfig.gameMapSize !== undefined) {
this.gameConfig.gameMapSize = gameConfig.gameMapSize;
}
Expand Down Expand Up @@ -648,12 +652,19 @@ export class GameServer {
return this.outOfSyncClients.size;
}

/** Begin the start sequence: reveal the map and broadcast prestart to clients. */
public prestart() {
if (this.hasStarted()) {
return;
}
this._hasPrestarted = true;

// The game is starting: reveal the map. Clearing the flag means every
// downstream consumer (embed, started-game info) sees a normal concrete
// map with no special-casing. The concrete gameMap was already resolved
// client-side at lobby creation, so nothing else needs to change here.
this.gameConfig.randomMap = false;

const prestartMsg = ServerPrestartMessageSchema.safeParse({
type: "prestart",
gameMap: this.gameConfig.gameMap,
Expand Down
Loading
Loading