diff --git a/src/app.css b/src/app.css index 73fb3b6..fdcea66 100644 --- a/src/app.css +++ b/src/app.css @@ -244,17 +244,45 @@ inset 0 1px rgba(255, 242, 181, 0.12); } + .mail-dialog-report { + height: min(680px, calc(100vh - 4rem)); + } + .mail-dialog-header { @apply flex items-center gap-3 border-b border-[#777354] bg-[#3a4e4c] px-4 py-3; box-shadow: inset 0 1px rgba(222, 240, 238, 0.12); } .mail-dialog-body { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; + background: #172326; + } + + .mail-round-log { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; + } + + .mail-round-log-list { min-height: 0; + } + + .mail-round-log-list-expanded { + flex: 1 1 auto; overflow-y: auto; scrollbar-color: #5e7378 #1c292c; scrollbar-width: thin; - background: #172326; + } + + .mail-round-log-toggle { + @apply w-full shrink-0 border-t border-white/[0.07] bg-white/[0.025] px-3 py-2 text-[9px] font-semibold text-[#b8c3bc] transition-colors hover:bg-white/[0.06] hover:text-white; } .mail-dialog-footer { @@ -523,6 +551,41 @@ box-shadow: inset 0 0 0 1px rgba(191, 219, 254, 0.08); } + .battle-casualty-composition { + @apply flex flex-wrap gap-1.5; + } + + .battle-casualty-chip { + @apply flex min-w-[6.25rem] items-center gap-1.5 border border-red-200/15 bg-black/[0.14] p-1 pr-1.5 text-red-100/90; + } + + .battle-casualty-chip .unit-token, + .battle-casualty-kind-token { + @apply flex h-7 w-7 shrink-0 items-center justify-center text-red-100/80; + border: 1px solid rgba(254, 202, 202, 0.16); + background: rgba(72, 32, 32, 0.22); + box-shadow: inset 0 1px rgba(254, 226, 226, 0.06); + } + + .battle-casualty-chip .unit-token > svg, + .battle-casualty-kind-token > svg { + @apply h-5 w-5; + } + + .battle-casualty-chip .unit-tier { + @apply min-w-3 text-[6px] leading-[10px]; + background: #281b1c; + border-color: rgba(254, 202, 202, 0.18); + } + + .battle-casualty-chip strong { + @apply block text-[10px] tabular-nums leading-none text-red-100; + } + + .battle-casualty-chip > span:last-child > span { + @apply mt-0.5 block truncate text-[7px] leading-none text-[#9b8d8b]; + } + .structure-token { @apply relative flex h-11 w-11 shrink-0 items-center justify-center text-[#d8e5e2]; border: 1px solid #62767b; @@ -608,6 +671,10 @@ max-height: calc(100vh - 1rem); } + .mail-dialog-report { + height: calc(100vh - 1rem); + } + .mail-dialog-footer { @apply block; } diff --git a/src/routes/game/+page.svelte b/src/routes/game/+page.svelte index 5074dd6..7820963 100644 --- a/src/routes/game/+page.svelte +++ b/src/routes/game/+page.svelte @@ -141,6 +141,7 @@ let policySaving = false; let activeGameTooltip: { title: string; detail: string; x: number; y: number; below: boolean } | null = null; let selectedMailboxMessageId: string | null = null; + let mailRoundLogExpanded = false; const showGameTooltip = (event: MouseEvent | FocusEvent, title: string, detail: string) => { const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); @@ -319,17 +320,10 @@ const troopStackTotal = (stacks: { count?: number }[]) => stacks.reduce((total, stack) => total + (stack.count ?? 0), 0); const troopStackCount = (stacks: { type: TroopType; count?: number }[], type: TroopType) => stacks.find((stack) => stack.type === type)?.count ?? 0; const battleMilitaryLosses = (losses?: BattleLossSummary) => troopStackTotal(losses?.troops ?? []) + Number(losses?.militia ?? 0n); - const battleLossParts = (losses?: BattleLossSummary) => [ - ...(losses?.troops ?? []).filter((stack) => (stack.count ?? 0) > 0).map((stack) => `${stack.count} ${troopName(stack.type, stack.count)}`), - ...(losses?.militia && losses.militia > 0n ? [`${losses.militia.toString()} militia`] : []), - ...(losses?.civilians && losses.civilians > 0n ? [`${losses.civilians.toString()} civilians`] : []) - ]; const reportArmyLosses = (army: BattleReportArmy) => troopStackTotal(army.startingTroops) - troopStackTotal(army.survivingTroops); const reportSideStart = (side?: ReportSide) => (side?.armies ?? []).reduce((total, army) => total + troopStackTotal(army.startingTroops), Number(side?.startingMilitia ?? 0n)); const reportSideSurvivors = (side?: ReportSide) => (side?.armies ?? []).reduce((total, army) => total + troopStackTotal(army.survivingTroops), Number(side?.survivingMilitia ?? 0n)); const reportLossTotal = (losses: BattleReportLoss[]) => losses.reduce((total, loss) => total + troopStackTotal(loss.troops) + Number(loss.militia), 0); - const reportLossDescription = (loss: BattleReportLoss) => - [...loss.troops.map((stack) => `${stack.count ?? 0} ${troopName(stack.type, stack.count)}`), ...(loss.militia > 0n ? [`${loss.militia.toString()} militia`] : [])].join(' · '); const reportLossSource = (report: BattleReport, loss: BattleReportLoss) => { if (loss.militiaCityId) { const settlement = [report.attackers?.settlement, report.defenders?.settlement].find((candidate) => candidate?.cityId?.value === loss.militiaCityId?.value); @@ -751,6 +745,7 @@ const id = message.mailboxMessageId?.value; if (!id) return; selectedMailboxMessageId = id; + mailRoundLogExpanded = false; if (message.readAt) return; try { const response = await mailboxClient.markMailboxMessageRead({ mailboxMessageId: message.mailboxMessageId }); @@ -2486,15 +2481,6 @@ {/snippet} -{#snippet reportRoundLosses(losses: BattleReportLoss[], report: BattleReport)} - {#each losses as loss} -
- {reportLossSource(report, loss)} - {reportLossDescription(loss)} -
- {/each} -{/snippet} - {#snippet populationSegment(label: string, count: number, detail: string, color: string, width: number)} {/snippet} +{#snippet casualtyKindGlyph(kind: 'militia' | 'civilian')} + +{/snippet} + +{#snippet casualtyComposition(troops: { type: TroopType; count?: number }[], militia: bigint = 0n, civilians: bigint = 0n, emptyLabel = 'No casualties')} + {@const troopLosses = troops.filter((stack) => (stack.count ?? 0) > 0)} +
+ {#each troopLosses as stack} + + {@render troopGlyph(stack.type)} + + {(stack.count ?? 0).toLocaleString()} + {troopName(stack.type, stack.count)} + + + {/each} + {#if militia > 0n} + + {@render casualtyKindGlyph('militia')} + + {militia.toLocaleString()} + Militia + + + {/if} + {#if civilians > 0n} + + {@render casualtyKindGlyph('civilian')} + + {civilians.toLocaleString()} + Civilians + + + {/if} + {#if troopLosses.length === 0 && militia === 0n && civilians === 0n} + {emptyLabel} + {/if} +
+{/snippet} + +{#snippet reportRoundCasualties(label: string, losses: BattleReportLoss[], civilians: bigint, report: BattleReport, attacking: boolean, power: number | null)} +
+
+ {label} + {#if power !== null}Power {Math.round(power).toLocaleString()}{/if} +
+
+ {#each losses.filter((loss) => troopStackTotal(loss.troops) > 0 || loss.militia > 0n) as loss} +
+ {reportLossSource(report, loss)} + {@render casualtyComposition(loss.troops, loss.militia)} +
+ {/each} + {#if civilians > 0n} +
+ Civilian population + {@render casualtyComposition([], 0n, civilians)} +
+ {/if} + {#if reportLossTotal(losses) === 0 && civilians === 0n} + No casualties + {/if} +
+
+{/snippet} + {#snippet structureGlyph(type: BuildingType, level: number)}
{label}
- {side?.armyIds.length ?? 0} - {(side?.armyIds.length ?? 0) === 1 ? 'formation' : 'formations'} · {side?.userIds.length ?? 0} + {formationCount} + {formationCount === 1 ? 'formation' : 'formations'} · {side?.userIds.length ?? 0} {(side?.userIds.length ?? 0) === 1 ? 'commander' : 'commanders'}
@@ -2781,27 +2845,41 @@
-
-
- Casualty detail - {cumulativeParts.length ? cumulativeParts.join(' · ') : 'None'} -
+
+
Total casualties
+ {@render casualtyComposition(side?.cumulativeLosses?.troops ?? [], side?.cumulativeLosses?.militia ?? 0n, side?.cumulativeLosses?.civilians ?? 0n, 'None')} {#if completedRounds > 0} -
- Round {completedRounds} - {lastRoundParts.length ? lastRoundParts.join(' · ') : 'No casualties'} +
+
Round {completedRounds}
+ {@render casualtyComposition(side?.lastRoundLosses?.troops ?? [], side?.lastRoundLosses?.militia ?? 0n, side?.lastRoundLosses?.civilians ?? 0n)}
{/if}
- {#if side?.militiaCityId} -
- Settlement militia - {side.strengthVisible ? side.militiaCount.toLocaleString() : 'Unknown'} -
- {/if} -
+ {#if side?.militiaCityId} +
+
+
+ +
+ Settlement militia + Local defense formation +
+
+
+ {side.strengthVisible ? side.militiaCount.toLocaleString() : '?'} + {side.strengthVisible ? 'units' : 'unknown size'} +
+
+
+ {/if} {#each side?.armyIds ?? [] as armyId} {@const army = sideArmies.find((candidate) => candidate.armyId?.value === armyId.value)} {#if army} @@ -2832,9 +2910,10 @@ {:else}
Formation details concealed
{/if} - {:else} -
No field armies disclosed
{/each} + {#if formationCount === 0} +
No field armies disclosed
+ {/if}
{/snippet} @@ -3118,12 +3197,19 @@ {#if selectedMailboxMessage} {@const report = selectedMailboxMessage.content.case === 'battleReport' ? selectedMailboxMessage.content.value : undefined} + {@const visibleReportRounds = report ? (mailRoundLogExpanded ? report.rounds : report.rounds.slice(0, 3)) : []} + {@const hiddenReportRoundCount = report ? Math.max(0, report.rounds.length - visibleReportRounds.length) : 0}
(selectedMailboxMessageId = null)} transition:fade={{ duration: 140 }} > -