Congress Speaker System
Overview#
The Congress Speaker System manages Speaker of the House elections in the US Congress. The Speaker is elected by House members, with voting restricted to the majority party.
Location: src/lib/congress/speaker/
Key files:
actions.ts- Action handlers (declare, withdraw, vote, start, reset, force_end)resolveSpeakerElection.ts- Election resolution logicresponseBuilder.ts- Response formattingvacateSpeakerIfLostSeat.ts- Auto-vacate logic when Speaker loses seattypes.ts- Type definitions
Election Rules#
Eligibility#
To run for Speaker:
- Must be a sitting House member
- Must be from the majority party
To vote for Speaker:
- Must be a sitting House member
- Must be from the majority party
Election Timing#
- Duration: 24 hours from start (
ELECTION_DURATION_MSinactions.ts/openSpeakerElection.ts) - End condition: Time expires OR admin force-ends
- Resolution: Plurality wins (most votes, no majority required)
NPP Voting#
NPPs in the majority party automatically vote for the incumbent Speaker if running, otherwise follow party leadership preferences.
// src/lib/turn/npp/speakerVoting.ts
export async function recalculateNPPSpeakerVotes(): Promise<void> {
// NPPs vote for incumbent if running, otherwise party leadership choice
}
Action Handlers#
start_election#
Authorization: Admin only
Behavior:
- Check no active election exists
- Clear failed nominations
- Create new 24-hour election window
- Auto-nominate incumbent Speaker if eligible (has seat, majority party)
- Trigger NPP vote recalculation
Response:
{
"success": true,
"message": "Speaker election started. Voting ends in 24 hours. Only the majority party may run and vote. Plurality wins."
}
reset_election#
Authorization: Admin only
Behavior:
- Clear all active nominations to "failed"
- Close current election
- Allows starting fresh election
Response:
{
"success": true,
"message": "Speaker election reset. You can start a new 24-hour election."
}
force_end#
Authorization: Admin only
Behavior:
- Immediately resolve current election
- Install winner or assign to NPP
Response:
{
"success": true,
"message": "Speaker election ended. Winner or NPP assignee is set."
}
declare#
Authorization: House member from majority party
Checks:
- Election is in voting phase
- Character is from majority party
- No existing candidacy (self or NPP)
- Not running for other leadership position (House/Senate)
Cost: None
Effects:
- Create SpeakerNomination record
- Trigger achievement check (
speaker_candidate) - Recalculate NPP votes
Response:
{
"success": true,
"message": "{name} has declared for Speaker. Voting ends {endTime}. Top vote-getter wins.",
"status": 201
}
withdraw#
Authorization: House member with active candidacy
Checks:
- Election is in voting phase
- Has active candidacy
- Has ≥3 political influence
Cost: 3 political influence
Effects:
- Deduct 3 NPI from character
- Log influence history
- Set candidacy status to "failed"
- Recalculate NPP votes
Response:
{
"success": true,
"message": "Candidacy withdrawn. 3 NPI deducted."
}
vote#
Authorization: House member from majority party
Checks:
- Election is in voting phase
- Character is from majority party
- Valid nomination ID
Behavior:
- Vote is changeable (can switch candidates)
- Previous vote is automatically removed
- Only one active vote per member
Effects:
- Remove previous vote (if any)
- Add vote to new nomination
- Increment votesFor counter
Response:
{
"success": true,
"message": "Vote recorded for {candidateName}. Top vote-getter when the window closes wins."
}
Election Resolution#
resolveSpeakerElection()#
Called when election ends (time expiry or admin force-end):
export async function resolveSpeakerElection(
db: Db,
partyMap: Map<string, PoliticalParty>,
forceEnd: boolean
): Promise<boolean> {
// 1. Find active election
const election = await db
.collection<SpeakerElection>("speakerElections")
.findOne({ _id: "current" });
if (!election || election.status !== "voting") return false;
// 2. Find winner (plurality)
const nominations = await db
.collection<SpeakerNomination>("speakerNominations")
.find({ status: { $in: ["open", "voting"] } })
.sort({ votesFor: -1 })
.toArray();
if (nominations.length === 0) {
// No candidates - assign to NPP
await assignNPPSpeaker(db, partyMap);
} else {
// Winner is top vote-getter
const winner = nominations[0];
await installSpeaker(db, winner.nomineeId, winner.nomineeName, winner.nomineeParty);
}
// 3. Close election
await db
.collection<SpeakerElection>("speakerElections")
.updateOne({ _id: "current" }, { $set: { status: "closed", updatedAt: new Date() } });
return true;
}
Tie Breaking#
Ties are broken by:
- First to reach the vote total (earlier
updatedAt) - If still tied, NPP assignment
Auto-Vacate Logic#
vacateSpeakerIfLostSeat()#
Called during election resolution phase:
export async function vacateSpeakerIfLostSeat(db: Db): Promise<void> {
const leaderDoc = await db
.collection<CongressLeader>("congressLeaders")
.findOne({ role: "speaker_of_the_house" });
if (!leaderDoc?.characterId) return;
const stillHasSeat = await db.collection<ElectedOfficial>("electedOfficials").findOne({
officeType: "house",
$or: [{ characterId: leaderDoc.characterId }, { nppId: leaderDoc.characterId }],
});
if (stillHasSeat) return;
const now = new Date();
await db
.collection<CongressLeader>("congressLeaders")
.updateOne(
{ role: "speaker_of_the_house" },
{ $set: { characterId: null, characterName: "Vacant", updatedAt: now } }
);
// The chair is now empty, open an election so the House can refill it without
// waiting on an admin to start one.
await openSpeakerElection(db, now);
}
The leader doc is not deleted; it is set to characterId: null / characterName: "Vacant" so the singleton is idempotent (subsequent calls early-return once already vacant, so the election opens exactly once per vacancy). A fresh 24-hour Speaker election is opened automatically as part of this call.
Trigger: Runs in Group 7 (Election Resolution) after general elections resolve.
Motion to Vacate#
A sitting House member can move to remove the current Speaker without waiting for the next general election.
Location: (handleFileVacateMotion, handleVoteVacateMotion), , collection speakerVacateMotions.
Filing (file_vacate_motion)#
- Authorization: Any sitting House member (not restricted to majority party).
- Precondition: There must be a sitting Speaker (
characterIdset on thespeaker_of_the_houseleader doc). A motion cannot be filed against a vacant chair. - Conflict guard: Rejected with 409 if a motion is already
votingand its window has not yet closed. - Effect: Creates/overwrites the singleton
speakerVacateMotionsdoc (_id: "current") with a 24-hour voting window (ELECTION_DURATION_MS). The filer's own vote is recorded as"for"immediately.
Voting (vote_vacate_motion)#
- Authorization: Any sitting House member.
- Vote values:
"for"(vacate) or"against"(keep). - Votes are tallied via
computeCongressLeadershipTally, which is seat-scoped and seat-weighted, and drops votes from members who have since lost their seat.
Resolution (resolveSpeakerVacateMotion)#
- Threshold to pass: Absolute majority of the House (
Math.floor(totalSeats / 2) + 1for-votes), not a plurality. - Can resolve early once the threshold is reached, or when the 24-hour window closes (whichever comes first).
- Concurrency-safe: the motion is claimed via a conditional
status: voting → passed|failedwrite so two concurrent resolvers cannot double-vacate. - On pass: the Speaker is vacated (
vacateCongressLeadershipRole) and a fresh 24-hour Speaker election is opened immediately (openSpeakerElection), shared with the lost-seat vacancy path. - On fail (window closed without reaching the threshold): the motion is marked
failedand the sitting Speaker stays in place.
Data Model#
Collections#
| Collection | Purpose |
|---|---|
speakerElections |
Current election state |
speakerNominations |
Candidacies and votes |
congressLeaders |
Installed Speaker |
electedOfficials |
House membership verification |
characters |
NPI deduction, career history |
influenceHistory |
NPI spending log |
Document Types#
interface SpeakerElection {
_id: "current"; // Singleton document
status: "voting" | "closed";
startedAt: Date;
endsAt: Date;
createdAt: Date;
updatedAt: Date;
}
interface SpeakerNomination {
_id: ObjectId;
nomineeId: ObjectId;
nomineeName: string;
nomineeParty: string;
nomineeState?: string;
nominatedById: ObjectId;
nominatedByName: string;
status: "open" | "voting" | "withdrawn" | "failed" | "elected";
votesFor: number;
votesAgainst: number;
votes: Record<string, "for" | "against">; // characterId -> vote
createdAt: Date;
updatedAt: Date;
}
interface CongressLeader {
_id: ObjectId;
role: "speaker_of_the_house";
characterId: ObjectId;
characterName: string;
party: string;
state: string;
electedAt: Date;
createdAt: Date;
updatedAt: Date;
}
Vote Structure#
Votes are stored as a map on the nomination document:
votes: {
"characterId1": "for",
"characterId2": "for",
// ...
}
Vote changing: When a member votes for a different candidate:
- Previous vote is
$unsetfrom old nomination - New vote is
$seton new nomination votesForcounters are adjusted atomically
NPP Vote Recalculation#
NPP votes are recalculated when:
- New candidate declares
- Candidate withdraws
- Election starts (incumbent auto-nomination)
await recalculateNPPSpeakerVotes();
NPP voting logic:
- Vote for incumbent if running
- Otherwise vote for party leadership's choice
- Based on NPP's home state and party alignment
Integration with Turn Processing#
Speaker-related phases in turn processing:
| Phase | Group | Purpose |
|---|---|---|
| Speaker election resolution | 4 (NPP behavior) | Shared leadership upkeep helpers; no live NPP speaker vote pass |
| Election resolution | 7 | Vacate speaker if lost seat |
Error Responses#
| Status | Condition |
|---|---|
| 400 | Invalid action, missing character, invalid nomination ID |
| 403 | Not House member, not majority party, insufficient NPI |
| 404 | Candidacy not found, no active election |
| 409 | Election already running, already running for office |
Related Systems#
- House Composition: - Majority party calculation
- NPP Behavior:
src/lib/turn/npp/- NPP bill voting, election entry, and current player-only leadership stance - Congress Leadership:
docs/design/congress-leadership.md- Leadership elections - Election Resolution:
src/lib/turn/election/- General election resolution