diff --git a/README.md b/README.md index 2986a9b8..06792e07 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,13 @@ npm run dev -- --host 0.0.0.0 Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels. +In the lobby, the host may deal random factions or start a prediction-card +faction draft. Draft picks proceed from the highest drawn number, and the new +factions are assigned together after every seat chooses. AI seats choose +automatically. Both methods clear human readiness; direct selection remains +available. Consensual faction swaps are not yet implemented. See +[faction allocation](docs/FACTION_ALLOCATION.md). + For the provisional two-person setup, the host enables **Two players, two factions each** in the lobby. Each person joins with one faction, then adds a second and uses **Active faction** to switch seats. Ready each faction before diff --git a/app/page.tsx b/app/page.tsx index 44d20352..fb9d3fb7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,6 +7,7 @@ import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-c import { RoomEntryConfirmation } from '@/components/room-entry-confirmation'; import { RulesetControls } from '@/components/ruleset-controls'; import { TwoPlayerControls } from '@/components/two-player-controls'; +import { FactionAllocation } from '@/components/faction-allocation'; import { randomId } from '@/lib/random-id'; import { SeatHandoverSetup, @@ -855,6 +856,7 @@ export default function Home() { {!roomUnavailable &&
{ if (twoPlayerEntry) void dispatchControlledFaction(twoPlayerEntry, false); }} retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code} />
} + {!roomUnavailable &&
} {roomUnavailable ?
void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} />
: } diff --git a/components/faction-allocation.tsx b/components/faction-allocation.tsx new file mode 100644 index 00000000..45a30a02 --- /dev/null +++ b/components/faction-allocation.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { FACTIONS } from '@/game/catalog'; +import type { Action, GameView } from '@/game/engine'; +import { Button } from '@/components/ui/button'; + +/** Lobby allocation uses public seat identities only. The engine owns the + * randomized order and validates every pick again at commitment. */ +export function FactionAllocation({ game, send, busy }: { + game: GameView; + send: (action: Action) => Promise; + busy: boolean; +}) { + if (game.status !== 'lobby') return null; + const draft = game.factionDraft; + const isHost = game.host === game.me; + const seatName = (id: string) => { + const player = game.players.find((candidate) => candidate.id === id); + return player ? `${player.name}${player.bot ? ' (AI)' : ''}` : 'Unknown player'; + }; + if (!draft) return
+

Choose factions

+

Players can choose their own factions. The host can also randomly deal factions or start a prediction-card draft.

+ {isHost && <> +

Random dealing changes the current choices immediately. Draft choices replace the current factions only after everyone has picked. Either option clears human readiness.

+ {game.players.length < 2 &&

At least two faction seats are needed to allocate factions.

} +
+ + +
+ } +
; + + const next = draft.order[draft.picks.length]; + const picked = new Map(draft.picks.map((pick) => [pick.player, pick.faction])); + const used = new Set(draft.picks.map((pick) => pick.faction)); + const available = FACTIONS.filter((faction) => + (faction.expansion === 'base' || game.expansions.includes(faction.expansion)) && !used.has(faction.id)); + const isTurn = next?.player === game.me; + return
+

Prediction-card faction draft

+

Highest prediction card chooses first. Existing factions remain in place until every seat has picked; other lobby changes wait for the draft to finish.

+
    + {draft.order.map((entry) => { + const choice = picked.get(entry.player); + const faction = FACTIONS.find((item) => item.id === choice); + return
  1. + {seatName(entry.player)} · prediction {entry.card}: {faction ? faction.name : next?.player === entry.player ? 'choosing now' : 'waiting'} +
  2. ; + })} +
+ {isTurn ? <> +

Choose your faction

+

Choose one of the remaining factions. Each faction can be chosen only once.

+
+ {available.map((faction) => )} +
+ : next &&

Waiting for {seatName(next.player)} to choose a faction{game.players.find((player) => player.id === next.player)?.bot ? '; the AI will choose automatically' : ''}.

} + {isHost && } +
; +} diff --git a/components/two-player-controls.tsx b/components/two-player-controls.tsx index c01456d8..920b5126 100644 --- a/components/two-player-controls.tsx +++ b/components/two-player-controls.tsx @@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; export function TwoPlayerControls({ - game, disabled, pendingEntry, entryRoom, entryProblem, retryDisabled, + game, disabled, draftLocked = false, pendingEntry, entryRoom, entryProblem, retryDisabled, onModeChange, onSwitch, onAdd, onRetry, onAddBot, onAddControlledBot, }: { game: GameView; disabled: boolean; + draftLocked?: boolean; pendingEntry: boolean; entryRoom?: string; entryProblem: string; @@ -29,6 +30,7 @@ export function TwoPlayerControls({ const [botChoice, setBotChoice] = useState(''); const [botDifficulty, setBotDifficulty] = useState('Medium'); const mode = game.twoPlayerControllers; + const lobbyLocked = disabled || draftLocked; const controlled = game.controlledFactionSeats ?? []; const modeLocked = !!mode?.controllers.some((controller) => controller.seats.length > 1); const botController = mode?.controllers.find((controller) => @@ -46,7 +48,7 @@ export function TwoPlayerControls({
{game.status === 'lobby' && game.host === game.me && ( )} @@ -72,33 +74,33 @@ export function TwoPlayerControls({ } {game.status === 'lobby' && controlled.length === 1 &&
{ event.preventDefault(); - if (choice && name.trim() && available.some((item) => item.id === choice)) onAdd(name.trim(), choice); + if (!lobbyLocked && choice && name.trim() && available.some((item) => item.id === choice)) onAdd(name.trim(), choice); }}>

Add your second faction

- setName(event.target.value)} /> -