From a165860204699a5bfcdc8f8ae5cf84e258881aad Mon Sep 17 00:00:00 2001 From: BananaHampster Date: Fri, 5 Apr 2024 18:12:49 -0700 Subject: [PATCH] add flag progression markers --- parsedlogs/hamp2.css | 22 ++++ src/constants.ts | 34 ++++-- src/flagMovementTracker.ts | 90 ++++++++++---- src/flagPace.ts | 217 ++++++++++++++++++++++++++++++--- src/html/template-summary.html | 43 ++++++- src/parserUtils.ts | 2 +- 6 files changed, 357 insertions(+), 51 deletions(-) diff --git a/parsedlogs/hamp2.css b/parsedlogs/hamp2.css index 2e18e8e..be9c697 100644 --- a/parsedlogs/hamp2.css +++ b/parsedlogs/hamp2.css @@ -7,6 +7,12 @@ body { font-size: .875rem; --team-a-color: rgba(175, 141, 195, 0.65); --team-b-color: rgba(127, 191, 123, 0.65); + --flag-pickup: rgb(17, 7, 38, 1); + --flag-fragged: rgba(81, 142, 166, 1); + --flag-thrown: rgb(180, 210, 217, 1); + --flag-dropped: rgba(217, 175, 139, 1); + --flag-returned: rgba(166, 109, 3, 1); + --flag-captured: rgb(17, 7, 38, 1); } .sidebar { @@ -272,6 +278,22 @@ td.team-deaths, th.team-deaths, padding-left: 10px; } +.toggles { + user-select: none; +} + +#flag-pace .chart .markers circle { + stroke: rgba(255, 255, 255, 0.3); +} + +#flag-pace .chart g.touch-labels text { + text-anchor: middle; + fill: white; + font-size: 12px; + cursor: pointer; + user-select: none; +} + /** team styles */ .team1 { diff --git a/src/constants.ts b/src/constants.ts index 9def3db..1216f7d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -44,19 +44,37 @@ export interface ScoringActivity { game_time_as_seconds: number; } -export interface FlagMovement { - player: string; // steamID +export type FlagMovement = FlagMovementBase & (FlagMovementReturn | FlagMovementFrag | FlagMovementOther); + +type FlagMovementBase = { + current_score: number; game_time_as_seconds: number, - current_score: number, - how_dropped: FlagDrop; } -export const enum FlagDrop { - Fragged = 0, - Captured, - Thrown, +type FlagMovementOther = { + type: FlagMovementType; + carrier: string; //alias } +export type FlagMovementFrag = { + type: FlagMovementType.Fragged, + carrier: string; // alias + fragger: string; // alias +} + +type FlagMovementReturn = { + type: FlagMovementType.Returned, +} + +export enum FlagMovementType { + Pickup, + Fragged, + Thrown, + Dropped, + Returned, + Captured +}; + export type TeamsOutputStatsDetailed = { [team in TeamColor]?: TeamOutputStatsDetailed; } export interface TeamOutputStatsDetailed { diff --git a/src/flagMovementTracker.ts b/src/flagMovementTracker.ts index 283bd76..049bd06 100644 --- a/src/flagMovementTracker.ts +++ b/src/flagMovementTracker.ts @@ -3,9 +3,8 @@ import EventType from './eventType.js'; import { EventSubscriber, EventHandlingPhase, HandlerRequest } from "./eventSubscriberManager.js"; import { RoundState } from "./roundState.js"; import { TeamScore } from "./parserUtils.js"; -import { FlagDrop, FlagMovement, ParsingError, PlayerClass, TeamColor, TeamFlagMovements } from "./constants.js"; +import { FlagMovement, FlagMovementType, ParsingError, TeamColor, TeamFlagMovements } from "./constants.js"; import Player from "./player.js"; -import { PlayerRoundStats } from "./player.js"; class TeamFlagRoundStats { public numberOfCaps: number = 0; @@ -158,8 +157,7 @@ export class FlagMovementTracker extends EventSubscriber { break; case EventType.PlayerPickedUpBonusFlag: { - this.flagRoundStatsByTeam[event.playerFrom!.team].flagEvents.push(event); - + // don't record this fact; just update flag status let flagStatusToUpdate = this.currentFlagStatusByTeam[event.data!.team!]; if (!flagStatusToUpdate.carrier || !flagStatusToUpdate.carrier.matches(event.playerFrom!)) { console.error("Bonus flag pickup seen by a player (" + event.playerFrom!.name + ") which wasn't carrying the flag" @@ -319,39 +317,83 @@ export class FlagMovementTracker extends EventSubscriber { const flagEvents = this.flagRoundStatsByTeam[team].flagEvents; - flagEvents.forEach(event => { + const teamFlagMovements = flagMovements[team] as FlagMovement[]; + flagEvents.forEach((event: Event) => { const player = event.playerFrom; - let howFlagWasDropped = FlagDrop.Fragged; switch (event.eventType) { case EventType.TeamFlagHoldBonus: runningScore[team] += this.pointsPerTeamFlagHoldBonus; - howFlagWasDropped = FlagDrop.Captured; + teamFlagMovements.push({ + type: FlagMovementType.Captured, + carrier: "", + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; - case EventType.PlayerCapturedBonusFlag: - runningScore[team] += this.pointsPerBonusCap; - howFlagWasDropped = FlagDrop.Captured; + case EventType.PlayerPickedUpFlag: + teamFlagMovements.push({ + type: FlagMovementType.Pickup, + carrier: player!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; - case EventType.PlayerCapturedFlag: - runningScore[team] += this.pointsPerCap; - howFlagWasDropped = FlagDrop.Captured; + case EventType.PlayerPickedUpBonusFlag: + return; // do nothing + case EventType.FlagReturn: + teamFlagMovements.push({ + type: FlagMovementType.Returned, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; case EventType.PlayerThrewFlag: - howFlagWasDropped = FlagDrop.Thrown; + teamFlagMovements.push({ + type: FlagMovementType.Thrown, + carrier: player!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; case EventType.PlayerFraggedPlayer: - howFlagWasDropped = FlagDrop.Fragged; + case EventType.PlayerCommitSuicide: + teamFlagMovements.push({ + type: FlagMovementType.Fragged, + fragger: player!.name, + carrier: event.playerTo!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; - default: + case EventType.PlayerLeftServer: + teamFlagMovements.push({ + type: FlagMovementType.Dropped, + carrier: player!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); break; + case EventType.PlayerCapturedBonusFlag: + runningScore[team] += this.pointsPerBonusCap; + teamFlagMovements.push({ + type: FlagMovementType.Captured, + carrier: player!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); + break; + case EventType.PlayerCapturedFlag: + runningScore[team] += this.pointsPerCap; + teamFlagMovements.push({ + type: FlagMovementType.Captured, + carrier: player!.name, + current_score: runningScore[team], + game_time_as_seconds: event.gameTimeAsSeconds!, + }); + break; + default: + return; } - const flagMovement: FlagMovement = { - game_time_as_seconds: event.gameTimeAsSeconds!, - player: player ? player.name : "", - current_score: runningScore[team], - how_dropped: howFlagWasDropped, - // TODO: add _who/what_ they were fragged by. - } - flagMovements[team].push(flagMovement); }); if (needToComputeTeamScore) { // only overwrite the team score if there was no teamScore event diff --git a/src/flagPace.ts b/src/flagPace.ts index 4b63fb8..87b7718 100644 --- a/src/flagPace.ts +++ b/src/flagPace.ts @@ -1,22 +1,41 @@ import { color } from 'd3-color'; import * as jsdom from 'jsdom'; -import { FlagMovement, ScoringActivity, TeamColor } from './constants.js'; +import { FlagMovement, FlagMovementFrag, FlagMovementType, ParsingError, ScoringActivity, TeamColor } from './constants.js'; -interface ScoreUpdate { +interface FlagUpdate { team: string, gameTimeAsSeconds: number, currentScore: number, + type: FlagMovementType, + title: string, } + +interface FlagProgression { + start: number; // game_time_as_seconds + end: number; + type: Omit; + title: string; +} + export default class FlagPaceChart { - data: ScoreUpdate[]; + data: FlagUpdate[]; + flagProgression: FlagProgression[][][] | undefined; constructor(roundsScoringActivity: ScoringActivity[]) { this.data = FlagPaceChart.flagCapsToScoreUpdates(roundsScoringActivity); + + try { + this.flagProgression = FlagPaceChart.flagUpdatesToProgression(this.data); + } catch (e) { + if (e instanceof ParsingError) { + console.error('failed to parse flag progression; skipping flag progression visualization'); + } + } } - private static flagCapsToScoreUpdates(roundsScoringActivity: ScoringActivity[]): ScoreUpdate[] { - let scoreUpdates: ScoreUpdate[] = []; + private static flagCapsToScoreUpdates(roundsScoringActivity: ScoringActivity[]): FlagUpdate[] { + let flagUpdates: FlagUpdate[] = []; roundsScoringActivity.forEach((scoringActivity, roundIndex) => { Object.keys(scoringActivity.flag_movements).forEach((team: string, flagMovementIndex) => { @@ -26,31 +45,152 @@ export default class FlagPaceChart { const currentTeamLabel = `Round ${roundIndex + 1}: ${TeamColor.toString(teamColor)} team`; if (teamFlagMovements.length > 0) { // Insert a starting entry for time=0s for all teams with at least one cap. - scoreUpdates.push({ + flagUpdates.push({ gameTimeAsSeconds: 0, currentScore: 0, - team: currentTeamLabel + type: FlagMovementType.Returned, + team: currentTeamLabel, + title: 'Dropped: start of round', }); } teamFlagMovements.forEach(flagMovement => { - scoreUpdates.push({ + const timestamp = Intl.DateTimeFormat('en-us', { minute: 'numeric', second: '2-digit' }).format(flagMovement.game_time_as_seconds * 1000); + let title = `[${timestamp}] `; + const movementType = flagMovement.type; + switch (movementType) { + case FlagMovementType.Captured: + title += `Captured by ${flagMovement.carrier}`; + break; + case FlagMovementType.Dropped: + title += `Dropped by ${flagMovement.carrier}`; + break; + case FlagMovementType.Fragged: + title += `${flagMovement.carrier} fragged by ${(flagMovement as FlagMovementFrag).fragger}` + break; + case FlagMovementType.Pickup: + title += `Grabbed by ${flagMovement.carrier}`; + break; + case FlagMovementType.Returned: + title += `Flag returned`; + break; + case FlagMovementType.Thrown: + title += `Thrown by ${flagMovement.carrier}`; + break; + default: + const type: never = movementType; + } + + flagUpdates.push({ gameTimeAsSeconds: flagMovement.game_time_as_seconds, currentScore: flagMovement.current_score, - team: currentTeamLabel + type: flagMovement.type, + team: currentTeamLabel, + title, }); }); if (teamFlagMovements.length > 0) { // Insert a terminal entry for time=game_time_as_seconds. - scoreUpdates.push({ + flagUpdates.push({ gameTimeAsSeconds: scoringActivity.game_time_as_seconds, currentScore: teamFlagMovements.at(-1)!.current_score, - team: currentTeamLabel + type: FlagMovementType.Dropped, + team: currentTeamLabel, + title: "Dropped: end of round", }); } } }); }); - return scoreUpdates; + return flagUpdates; + } + + private static flagUpdatesToProgression(flagUpdates: FlagUpdate[]): FlagProgression[][][] { + let progression: FlagProgression[][][] = [[[]]]; + let teamLabels: Record = {}; + + let currentProgression: FlagProgression[] = []; + let currentTeam = -1; + let currentFlag = 0; + let activeFlag: FlagProgression | null = null; + for (const update of flagUpdates) { + if (teamLabels[update.team] == null) { + teamLabels[update.team] = ++currentTeam; + progression[currentTeam] = [[]]; + + currentFlag = 0; + currentProgression = progression[currentTeam][currentFlag]; + } + else if (teamLabels[update.team] !== currentTeam) { + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: 'expected that flagUpdates are in order', + }); + } + + if (activeFlag === null) { + if (update.type === FlagMovementType.Pickup) { + activeFlag = { + start: update.gameTimeAsSeconds, + title: update.title, + end: update.gameTimeAsSeconds, // will be updated + type: update.type, // will be updated + } + } + else if (update.type === FlagMovementType.Returned) { + currentProgression.push({ + start: update.gameTimeAsSeconds, + title: update.title, + end: update.gameTimeAsSeconds, + type: update.type + }); + } + else if (update.type === FlagMovementType.Dropped) { + continue; // show nothing; flag wasn't moving anyway (e.g., end of round) + } + else { + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: 'expected to see flag pickup/return before subsequent event' + }); + } + } + else { + if (update.type === FlagMovementType.Pickup) { // NOTE: if server doesn't log flag throws, assume player had flag up until flag is touched again (not great) + activeFlag.end = update.gameTimeAsSeconds + activeFlag.type = FlagMovementType.Thrown, + activeFlag.title = `Assume thrown`; + currentProgression.push(activeFlag); + + activeFlag = { + start: update.gameTimeAsSeconds, + end: update.gameTimeAsSeconds, + title: update.title, + type: update.type + }; + } + else if (update.type !== FlagMovementType.Returned) { + activeFlag.end = update.gameTimeAsSeconds; + activeFlag.type = update.type; + activeFlag.title = update.title; + + // add to progression and reset active + currentProgression.push(activeFlag); + activeFlag = null; + + if (update.type === FlagMovementType.Captured) { + currentProgression = progression[currentTeam][++currentFlag] = []; + } + } + else { + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: 'expected to see flag drop/throw/frag/capture/pickup after pickup', + }); + } + } + } + + return progression; } public async getSvgMarkup(): Promise { @@ -85,13 +225,17 @@ export default class FlagPaceChart { height: svgDimensions.height - margin.bottom - margin.top }; - let x = (scoreUpdate: ScoreUpdate) => scoreUpdate.gameTimeAsSeconds; - let y = (scoreUpdate: ScoreUpdate) => scoreUpdate.currentScore; - let z = (scoreUpdate: ScoreUpdate) => scoreUpdate.team; + let x = (scoreUpdate: FlagUpdate) => scoreUpdate.gameTimeAsSeconds; + let y = (scoreUpdate: FlagUpdate) => scoreUpdate.currentScore; + let z = (scoreUpdate: FlagUpdate) => scoreUpdate.team; + let t = (scoreUpdate: FlagUpdate) => scoreUpdate.type; + let s = (scoreUpdate: FlagUpdate) => scoreUpdate.title; const X = d3.map(this.data, x); const Y = d3.map(this.data, y); const Z = d3.map(this.data, z); + const T = d3.map(this.data, t); + const S = d3.map(this.data, s); const I = d3.range(this.data.length); const defined = (d, i: number) => !isNaN(X[i]) && !isNaN(Y[i]); const D = d3.map(this.data, defined); @@ -105,6 +249,10 @@ export default class FlagPaceChart { const xScale = d3.scaleLinear(xDomain, xRange); const yScale = d3.scaleLinear(yDomain, yRange); const color = d3.scaleOrdinal(zDomain, colors); + const typeColor = d3.scaleOrdinal( + [0, 1, 2, 3, 4, 5], + ['var(--flag-pickup)', 'var(--flag-fragged)', 'var(--flag-thrown)', 'var(--flag-dropped)', 'var(--flag-returned)', 'var(--flag-captured)'] + ); const xAxis = d3.axisBottom(xScale).ticks(Math.round(this.data.at(-1)!.gameTimeAsSeconds / 20)) .tickFormat((domainValue, index) => { if (domainValue.valueOf() % 60 == 0) { @@ -145,13 +293,12 @@ export default class FlagPaceChart { .attr("text-anchor", "start") .text(yLabel)); - const serie = svg.append("g") .selectAll("g") .data(d3.group(I, i => Z[i])) .join("g"); - const path = serie.append("path") + serie.append("path") .attr("fill", "none") .attr("stroke", ([key]) => color(key)) .attr("stroke-width", strokeWidth) @@ -161,6 +308,42 @@ export default class FlagPaceChart { .style("mix-blend-mode", mixBlendMode) .attr("d", ([, I]) => line(I)); + // only add flag progression markers/labels if we know that what we have is good + if (this.flagProgression != null) { + const jitter = d3.scaleOrdinal([-6, 6]); + serie.append('g') + .attr('class', 'markers') + .attr('transform', ([grp,]) => `translate(0, ${jitter(grp)})`) + .selectAll('circle') + .data(function (d) { return d[1].filter(i => T[i] !== 5); }) + .join('circle') + .attr('class', i => `flag-${T[i]}`) + .attr('cx', i => xScale(X[i])) + .attr('cy', i => yScale(Y[i])) + .attr('r', '4') + .style('fill', i => typeColor(T[i])) + .append('title') + .text(i => S[i]); + + const touches: number[] = []; + for (const team in this.flagProgression) { + for (const flag of this.flagProgression[team]) { + touches.push(flag.length); + } + } + serie.append('g') + .attr('class', 'touch-labels') + .selectAll('text') + .data(function(d) { return d[1].filter(i => T[i] === 5); }) + .join('text') + .attr('x', i => xScale(X[i])) + .attr('y', i => yScale(Y[i])) + .attr('dy', '-0.66em') + .text((_,i) => touches[i]) + .append('title') + .text((i, index) => `${S[i]} (${touches[index]} touches)`); + } + return document.body.children[0].outerHTML; } diff --git a/src/html/template-summary.html b/src/html/template-summary.html index 2d8d342..30ef718 100644 --- a/src/html/template-summary.html +++ b/src/html/template-summary.html @@ -281,6 +281,28 @@

Flag pace

{{{chartMarkup}}}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
{{#ifListNotEmpty parsing_errors}} @@ -292,7 +314,7 @@

Parsing errors

Round {{math @index "+" 1}}
    {{#each this}} -
  • {{this}}
  • +
  • {{this}}
  • {{/each}}
{{/if}} @@ -303,6 +325,7 @@
Round {{math @index "+" 1}}
+ diff --git a/src/parserUtils.ts b/src/parserUtils.ts index d8e3705..d199cd8 100644 --- a/src/parserUtils.ts +++ b/src/parserUtils.ts @@ -2,7 +2,7 @@ import PlayerList from "./playerList.js"; import { Event, RoundParser } from "./parser.js"; import Player from "./player.js"; import { RoundState } from "./roundState.js"; -import { TeamColor, TeamComposition, OutputStats, DisplayStringHelper, TeamStatsComparison, TeamRole, TeamStats, OffenseTeamStats, DefenseTeamStats, OutputPlayer, PlayerOutputStatsRound, TeamsOutputStatsDetailed, GenericStat, TeamOutputStatsDetailed, StatDetails, FacetedStat, EventDescriptor, Weapon, FacetedStatSummary, TeamFlagMovements, FlagMovement, FlagDrop, FacetedStatDetails } from "./constants.js"; +import { TeamColor, TeamComposition, OutputStats, DisplayStringHelper, TeamStatsComparison, TeamRole, TeamStats, OffenseTeamStats, DefenseTeamStats, OutputPlayer, PlayerOutputStatsRound, TeamsOutputStatsDetailed, GenericStat, TeamOutputStatsDetailed, StatDetails, FacetedStat, EventDescriptor, Weapon, FacetedStatSummary, FacetedStatDetails } from "./constants.js"; import EventType from "./eventType.js"; export type TeamScore = { [team in TeamColor]?: number; };