feat: simplify Angular host/player MVP flow for issue #276 #281

Merged
integrator-bot merged 1 commits from feat/issue-276-angular-i18n-audio-guard into main 2026-03-13 09:39:39 +01:00
5 changed files with 144 additions and 30 deletions

View File

@@ -262,5 +262,26 @@ describe('HostShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(false);
expect(component.canMixAnswers).toBe(false);
expect(component.canCalculateScores).toBe(true);
});
it('uses phase_view_model to keep host action surface phase-specific', async () => {
const component = new HostShellComponent();
expect(component.canStartRound).toBe(true);
expect(component.canShowQuestion).toBe(false);
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canMixAnswers).toBe(true);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canRevealScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
});
});

View File

@@ -11,6 +11,17 @@ interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
phase_view_model?: {
host?: {
can_start_round?: boolean;
can_show_question?: boolean;
can_mix_answers?: boolean;
can_calculate_scores?: boolean;
can_reveal_scoreboard?: boolean;
can_start_next_round?: boolean;
can_finish_game?: boolean;
};
};
}
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
@@ -25,18 +36,15 @@ type LeaderboardResponse = FinishGameResponse;
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">{{ copy('host.retry_finish') }}</button>
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button *ngIf="canShowQuestion" (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button *ngIf="canMixAnswers" (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button *ngIf="canCalculateScores" (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button *ngIf="canRevealScoreboard || scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy(scoreboardError ? 'host.retry_scoreboard' : 'host.load_scoreboard') }}</button>
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button>
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button>
</div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
@@ -114,6 +122,34 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null;
}
get canStartRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session);
}
get canShowQuestion(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_show_question);
}
get canMixAnswers(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_mix_answers);
}
get canCalculateScores(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores);
}
get canRevealScoreboard(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard);
}
get canStartNextRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_next_round);
}
get canFinishGame(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_finish_game);
}
copy(key: string): string {
return t(key, this.locale);
}

View File

@@ -518,4 +518,28 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.clientHasNoAudioOutput).toBe(false);
});
it('keeps phone client controls phase-specific and low-complexity', () => {
const component = new PlayerShellComponent();
expect(component.showJoinControls).toBe(true);
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(false);
component.session = sessionDetailPayload('lie') as any;
component.playerId = 9;
component.sessionToken = 'tok';
expect(component.showJoinControls).toBe(false);
expect(component.showLieControls).toBe(true);
expect(component.showGuessControls).toBe(false);
component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any;
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(true);
component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any;
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(true);
});
});

View File

@@ -11,6 +11,14 @@ interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
phase_view_model?: {
player?: {
can_join?: boolean;
can_submit_lie?: boolean;
can_submit_guess?: boolean;
can_view_final_result?: boolean;
};
};
}
type ConnectionState = 'online' | 'reconnecting' | 'offline';
@@ -49,9 +57,9 @@ function resolveLocalStorage(): Storage | undefined {
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<label *ngIf="showJoinControls">{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
<button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
@@ -71,26 +79,30 @@ function resolveLocalStorage(): Storage | undefined {
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
<ng-container *ngIf="showLieControls">
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label>
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
</ng-container>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
>
{{ answer.text }}
</button>
</div>
<ng-container *ngIf="showGuessControls">
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
@@ -300,6 +312,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}, 3000);
}
get showJoinControls(): boolean {
if (!this.session) {
return true;
}
return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken);
}
get showLieControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_lie);
}
get showGuessControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_guess);
}
get showFinalLeaderboard(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_view_final_result);
}
get loadingMessage(): string {
switch (this.loadingTransition) {
case 'join':

View File

@@ -26,11 +26,13 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
host.ngOnInit();
player.ngOnInit();
expect(host.copy('common.refresh')).toBe('Refresh');
expect(host.copy('game.host.start_round')).toBe('Start round');
expect(player.copy('game.player.submit_guess')).toBe('Submit guess');
setPreferredLocale('da');
expect(host.copy('common.refresh')).toBe('Opdatér');
expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('game.player.submit_guess')).toBe('Send gæt');