blob: 21fd474dcab920a8e6f59bfbb6d865d5769853a6 [file] [log] [blame]
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Builder, ByteBuffer} from 'flatbuffers';
import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
import {
StartMatchAction,
ScoreType,
StageType,
Submit2024Actions,
MobilityAction,
PenaltyAction,
PickupNoteAction,
PlaceNoteAction,
RobotDeathAction,
EndMatchAction,
ActionType,
Action,
} from '../../webserver/requests/messages/submit_2024_actions_generated';
import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
import {MatchListRequestor} from '../rpc';
import * as pako from 'pako';
type Section =
| 'Team Selection'
| 'Init'
| 'Pickup'
| 'Place'
| 'Endgame'
| 'Dead'
| 'Review and Submit'
| 'QR Code'
| 'Success';
// TODO(phil): Deduplicate with match_list.component.ts.
const COMP_LEVELS = ['qm', 'ef', 'qf', 'sf', 'f'] as const;
type CompLevel = typeof COMP_LEVELS[number];
// TODO(phil): Deduplicate with match_list.component.ts.
const COMP_LEVEL_LABELS: Record<CompLevel, string> = {
qm: 'Qualifications',
ef: 'Eighth Finals',
qf: 'Quarter Finals',
sf: 'Semi Finals',
f: 'Finals',
};
// The maximum number of bytes per QR code. The user can adjust this value to
// make the QR code contain less information, but easier to scan.
const QR_CODE_PIECE_SIZES = [150, 300, 450, 600, 750, 900];
// The default index into QR_CODE_PIECE_SIZES.
const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
type ActionT =
| {
type: 'startMatchAction';
timestamp?: number;
position: number;
}
| {
type: 'mobilityAction';
timestamp?: number;
mobility: boolean;
}
| {
type: 'pickupNoteAction';
timestamp?: number;
auto?: boolean;
}
| {
type: 'placeNoteAction';
timestamp?: number;
scoreType: ScoreType;
auto?: boolean;
}
| {
type: 'robotDeathAction';
timestamp?: number;
robotDead: boolean;
}
| {
type: 'penaltyAction';
timestamp?: number;
penalties: number;
}
| {
type: 'endMatchAction';
stageType: StageType;
trapNote: boolean;
spotlight: boolean;
timestamp?: number;
}
| {
// This is not a action that is submitted,
// It is used for undoing purposes.
type: 'endAutoPhase';
timestamp?: number;
}
| {
// This is not a action that is submitted,
// It is used for undoing purposes.
type: 'endTeleopPhase';
timestamp?: number;
};
@Component({
selector: 'app-entry',
templateUrl: './entry.ng.html',
styleUrls: ['../app/common.css', './entry.component.css'],
})
export class EntryComponent implements OnInit {
// Re-export the type here so that we can use it in the `[value]` attribute
// of radio buttons.
readonly COMP_LEVELS = COMP_LEVELS;
readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
readonly ScoreType = ScoreType;
readonly StageType = StageType;
section: Section = 'Team Selection';
@Input() matchNumber: number = 1;
@Input() teamNumber: string = '1';
@Input() setNumber: number = 1;
@Input() compLevel: CompLevel = 'qm';
@Input() skipTeamSelection = false;
@ViewChild('header') header: ElementRef;
matchList: Match[] = [];
actionList: ActionT[] = [];
progressMessage: string = '';
errorMessage: string = '';
autoPhase: boolean = true;
mobilityCompleted: boolean = false;
selectedValue = 0;
nextTeamNumber = '';
preScouting: boolean = false;
matchStartTimestamp: number = 0;
penalties: number = 0;
teamSelectionIsValid = false;
// When the user chooses to generate QR codes, we convert the flatbuffer into
// a long string. Since we frequently have more data than we can display in a
// single QR code, we break the data into multiple QR codes. The data for
// each QR code ("pieces") is stored in the `qrCodeValuePieces` list below.
// The `qrCodeValueIndex` keeps track of which QR code we're currently
// displaying.
qrCodeValuePieceSize = QR_CODE_PIECE_SIZES[DEFAULT_QR_CODE_PIECE_SIZE_INDEX];
qrCodeValuePieces: string[] = [];
qrCodeValueIndex: number = 0;
constructor(private readonly matchListRequestor: MatchListRequestor) {}
ngOnInit() {
// When the user navigated from the match list, we can skip the team
// selection. I.e. we trust that the user clicked the correct button.
this.section = this.skipTeamSelection ? 'Init' : 'Team Selection';
this.fetchMatchList();
}
goToNextTeam() {
this.ngOnInit();
this.teamNumber = this.nextTeamNumber;
this.nextTeamNumber = '';
}
async fetchMatchList() {
this.progressMessage = 'Fetching match list. Please be patient.';
this.errorMessage = '';
try {
this.matchList = await this.matchListRequestor.fetchMatchList();
this.progressMessage = 'Successfully fetched match list.';
} catch (e) {
this.errorMessage = e;
this.progressMessage = '';
}
}
// This gets called when the user changes something on the Init screen.
// It makes sure that the user can't click "Next" until the information is
// valid, or this is for pre-scouting.
updateTeamSelectionValidity(): void {
this.teamSelectionIsValid = this.preScouting || this.matchIsInMatchList();
}
matchIsInMatchList(): boolean {
// If the user deletes the content of the teamNumber field, the value here
// is undefined. Guard against that.
if (this.teamNumber == null) {
return false;
}
for (const match of this.matchList) {
if (
this.matchNumber == match.matchNumber() &&
this.setNumber == match.setNumber() &&
this.compLevel == match.compLevel() &&
(this.teamNumber === match.r1() ||
this.teamNumber === match.r2() ||
this.teamNumber === match.r3() ||
this.teamNumber === match.b1() ||
this.teamNumber === match.b2() ||
this.teamNumber === match.b3())
) {
return true;
}
}
return false;
}
addPenalty(): void {
this.penalties += 1;
}
removePenalty(): void {
if (this.penalties > 0) {
this.penalties -= 1;
}
}
addPenalties(): void {
this.addAction({type: 'penaltyAction', penalties: this.penalties});
}
addAction(action: ActionT): void {
if (action.type == 'startMatchAction') {
// Unix nanosecond timestamp.
this.matchStartTimestamp = Date.now() * 1e6;
action.timestamp = 0;
} else {
// Unix nanosecond timestamp relative to match start.
action.timestamp = Date.now() * 1e6 - this.matchStartTimestamp;
}
if (action.type == 'endMatchAction') {
// endMatchAction occurs at the same time as penaltyAction so add to its
// timestamp to make it unique.
action.timestamp += 1;
}
if (action.type == 'mobilityAction') {
this.mobilityCompleted = true;
}
if (action.type == 'pickupNoteAction' || action.type == 'placeNoteAction') {
action.auto = this.autoPhase;
}
this.actionList.push(action);
}
undoLastAction() {
if (this.actionList.length > 0) {
let lastAction = this.actionList.pop();
switch (lastAction?.type) {
case 'endAutoPhase':
this.autoPhase = true;
this.section = 'Pickup';
case 'pickupNoteAction':
this.section = 'Pickup';
break;
case 'endTeleopPhase':
this.section = 'Pickup';
break;
case 'placeNoteAction':
this.section = 'Place';
break;
case 'endMatchAction':
this.section = 'Endgame';
case 'mobilityAction':
this.mobilityCompleted = false;
break;
case 'startMatchAction':
this.section = 'Init';
break;
case 'robotDeathAction':
// TODO(FILIP): Return user to the screen they
// clicked dead robot on. Pickup is fine for now but
// might cause confusion.
this.section = 'Pickup';
break;
default:
break;
}
}
}
stringifyScoreType(scoreType: ScoreType): String {
return ScoreType[scoreType];
}
stringifyStageType(stageType: StageType): String {
return StageType[stageType];
}
changeSectionTo(target: Section) {
// Clear the messages since they won't be relevant in the next section.
this.errorMessage = '';
this.progressMessage = '';
// For the QR code screen, we need to make the value to encode available.
if (target == 'QR Code') {
this.updateQrCodeValuePieceSize();
}
this.section = target;
}
private scrollToTop() {
this.header.nativeElement.scrollIntoView();
}
createActionsBuffer() {
const builder = new Builder();
const actionOffsets: number[] = [];
for (const action of this.actionList) {
let actionOffset: number | undefined;
switch (action.type) {
case 'startMatchAction':
const startMatchActionOffset =
StartMatchAction.createStartMatchAction(builder, action.position);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.StartMatchAction,
startMatchActionOffset
);
break;
case 'mobilityAction':
const mobilityActionOffset = MobilityAction.createMobilityAction(
builder,
action.mobility
);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.MobilityAction,
mobilityActionOffset
);
break;
case 'penaltyAction':
const penaltyActionOffset = PenaltyAction.createPenaltyAction(
builder,
action.penalties
);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.PenaltyAction,
penaltyActionOffset
);
break;
case 'pickupNoteAction':
const pickupNoteActionOffset =
PickupNoteAction.createPickupNoteAction(
builder,
action.auto || false
);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.PickupNoteAction,
pickupNoteActionOffset
);
break;
case 'placeNoteAction':
const placeNoteActionOffset = PlaceNoteAction.createPlaceNoteAction(
builder,
action.scoreType,
action.auto || false
);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.PlaceNoteAction,
placeNoteActionOffset
);
break;
case 'robotDeathAction':
const robotDeathActionOffset =
RobotDeathAction.createRobotDeathAction(builder, action.robotDead);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.RobotDeathAction,
robotDeathActionOffset
);
break;
case 'endMatchAction':
const endMatchActionOffset = EndMatchAction.createEndMatchAction(
builder,
action.stageType,
action.trapNote,
action.spotlight
);
actionOffset = Action.createAction(
builder,
BigInt(action.timestamp || 0),
ActionType.EndMatchAction,
endMatchActionOffset
);
break;
case 'endAutoPhase':
// Not important action.
break;
case 'endTeleopPhase':
// Not important action.
break;
default:
throw new Error(`Unknown action type`);
}
if (actionOffset !== undefined) {
actionOffsets.push(actionOffset);
}
}
const teamNumberFb = builder.createString(this.teamNumber);
const compLevelFb = builder.createString(this.compLevel);
const actionsVector = Submit2024Actions.createActionsListVector(
builder,
actionOffsets
);
Submit2024Actions.startSubmit2024Actions(builder);
Submit2024Actions.addTeamNumber(builder, teamNumberFb);
Submit2024Actions.addMatchNumber(builder, this.matchNumber);
Submit2024Actions.addSetNumber(builder, this.setNumber);
Submit2024Actions.addCompLevel(builder, compLevelFb);
Submit2024Actions.addActionsList(builder, actionsVector);
Submit2024Actions.addPreScouting(builder, this.preScouting);
builder.finish(Submit2024Actions.endSubmit2024Actions(builder));
return builder.asUint8Array();
}
// Same as createActionsBuffer, but encoded as Base64. It's also split into
// a number of pieces so that each piece is roughly limited to
// `qrCodeValuePieceSize` bytes.
createBase64ActionsBuffers(): string[] {
const originalBuffer = this.createActionsBuffer();
const deflatedData = pako.deflate(originalBuffer, {level: 9});
const pieceSize = this.qrCodeValuePieceSize;
const fullValue = btoa(String.fromCharCode(...deflatedData));
const numPieces = Math.ceil(fullValue.length / pieceSize);
let splitData: string[] = [];
for (let i = 0; i < numPieces; i++) {
const splitPiece = fullValue.slice(i * pieceSize, (i + 1) * pieceSize);
splitData.push(`${i}_${numPieces}_${pieceSize}_${splitPiece}`);
}
return splitData;
}
setQrCodeValueIndex(index: number) {
this.qrCodeValueIndex = Math.max(
0,
Math.min(index, this.qrCodeValuePieces.length - 1)
);
}
updateQrCodeValuePieceSize() {
this.qrCodeValuePieces = this.createBase64ActionsBuffers();
this.qrCodeValueIndex = 0;
}
async submit2024Actions() {
const res = await fetch('/requests/submit/submit_2024_actions', {
method: 'POST',
body: this.createActionsBuffer(),
});
if (res.ok) {
// We successfully submitted the data. Report success.
this.section = 'Success';
this.actionList = [];
// Keep track of the position of the last robot, use to figure out what
// the next robot in the same position is.
let lastTeamPos = '0';
for (const match of this.matchList) {
if (
this.matchNumber === match.matchNumber() &&
this.setNumber === match.setNumber() &&
this.compLevel === match.compLevel()
) {
this.teamNumber = this.teamNumber;
if (this.teamNumber == match.r1()) {
lastTeamPos = 'r1';
} else if (this.teamNumber == match.r2()) {
lastTeamPos = 'r2';
} else if (this.teamNumber == match.r3()) {
lastTeamPos = 'r3';
} else if (this.teamNumber == match.b1()) {
lastTeamPos = 'b1';
} else if (this.teamNumber == match.b2()) {
lastTeamPos = 'b2';
} else if (this.teamNumber == match.b3()) {
lastTeamPos = 'b3';
} else {
console.log('Position of scouted team not found.');
}
break;
}
}
if (lastTeamPos != '0') {
this.matchNumber += 1;
for (const match of this.matchList) {
if (
this.matchNumber == match.matchNumber() &&
this.setNumber == match.setNumber() &&
this.compLevel == match.compLevel()
) {
if (lastTeamPos == 'r1') {
this.nextTeamNumber = match.r1();
} else if (lastTeamPos == 'r2') {
this.nextTeamNumber = match.r2();
} else if (lastTeamPos == 'r3') {
this.nextTeamNumber = match.r3();
} else if (lastTeamPos == 'b1') {
this.nextTeamNumber = match.b1();
} else if (lastTeamPos == 'b2') {
this.nextTeamNumber = match.b2();
} else if (lastTeamPos == 'b3') {
this.nextTeamNumber = match.b3();
} else {
console.log('Position of last team not found.');
}
break;
}
}
} else {
console.log('Last team position not found.');
}
this.matchList = [];
this.progressMessage = '';
this.errorMessage = '';
this.autoPhase = true;
this.actionList = [];
this.mobilityCompleted = false;
this.preScouting = false;
this.matchStartTimestamp = 0;
this.selectedValue = 0;
} else {
const resBuffer = await res.arrayBuffer();
const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
const errorMessage = parsedResponse.errorMessage();
this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
}
}
}