diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 41c02b2..58c1bc8 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -14,6 +14,20 @@ export const routes: Routes = [ (module) => module.WorldPageComponent, ), }, + { + path: 'hunt', + loadComponent: () => + import('./features/hunting/hunt-page/hunt-page.component').then( + (module) => module.HuntPageComponent, + ), + }, + { + path: 'combat/new', + loadComponent: () => + import('./features/combat/combat-placeholder-page.component').then( + (module) => module.CombatPlaceholderPageComponent, + ), + }, ], }, { path: '**', redirectTo: 'world' }, diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 1bcb192..760ebeb 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -1,6 +1,6 @@ import { signal, WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { Router, provideRouter } from '@angular/router'; import { CharacterResponse } from './core/api/game-api.models'; import { WorldStore } from './features/world/world.store'; import { AppShellComponent } from './layout/app-shell/app-shell.component'; @@ -19,7 +19,10 @@ describe('App', () => { await TestBed.configureTestingModule({ imports: [AppShellComponent], providers: [ - provideRouter([]), + provideRouter([ + { path: 'world', children: [] }, + { path: 'hunt', children: [] }, + ]), { provide: WorldStore, useValue: { character, currentLocation, selectedConnection }, @@ -28,9 +31,12 @@ describe('App', () => { }).compileComponents(); }); - it('renders the reusable game shell with only Karte available', () => { + it('renders the reusable game shell with Karte and Jagd available', async () => { const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/world'); fixture.detectChanges(); + await fixture.whenStable(); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('app-top-bar')).not.toBeNull(); @@ -44,7 +50,13 @@ describe('App', () => { expect(mapButton?.getAttribute('aria-current')).toBe('page'); expect(mapButton?.getAttribute('aria-label')).toBe('Karte'); - for (const destination of ['hunt', 'quests', 'inventory', 'character']) { + const huntButton = element.querySelector('[data-navigation="hunt"]'); + expect(huntButton).not.toBeNull(); + expect(huntButton?.disabled).toBe(false); + expect(huntButton?.getAttribute('aria-current')).toBeNull(); + expect(huntButton?.getAttribute('aria-label')).toBe('Jagd'); + + for (const destination of ['quests', 'inventory', 'character']) { expect( element.querySelector(`[data-navigation="${destination}"]`)?.disabled, ).toBe(true); @@ -53,6 +65,21 @@ describe('App', () => { expect(element.textContent).not.toContain('Shop'); }); + it('marks Jagd as the active navigation entry while on /hunt', async () => { + const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/hunt'); + fixture.detectChanges(); + await fixture.whenStable(); + + const element = fixture.nativeElement as HTMLElement; + const mapButton = element.querySelector('[data-navigation="world"]'); + const huntButton = element.querySelector('[data-navigation="hunt"]'); + + expect(huntButton?.getAttribute('aria-current')).toBe('page'); + expect(mapButton?.getAttribute('aria-current')).toBeNull(); + }); + it('renders loaded character values supplied by the WorldStore', () => { character.set({ id: 'character-id', diff --git a/apps/web/src/app/features/combat/combat-placeholder-page.component.ts b/apps/web/src/app/features/combat/combat-placeholder-page.component.ts new file mode 100644 index 0000000..bffeff5 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-placeholder-page.component.ts @@ -0,0 +1,72 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-combat-placeholder-page', + template: ` +
+ KAMPF +

Vorbereitung auf den Kampf

+

+ Die Klinge ist gezogen, der Gegner steht bereit – doch das eigentliche Gefecht liegt noch + vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem + späteren Schritt folgt. +

+ @if (encounterId) { +

+ Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}… +

+ } +
+ `, + styles: [ + ` + :host { + display: block; + } + + .combat-placeholder { + display: grid; + gap: var(--ar-space-3); + max-inline-size: 40rem; + margin: var(--ar-space-6) auto; + padding: var(--ar-space-5); + border: 1px solid var(--ar-border); + background: var(--ar-panel); + box-shadow: var(--ar-shadow-raised); + text-align: center; + } + + .combat-placeholder__eyebrow { + color: var(--ar-gold); + font-size: var(--ar-font-sm); + letter-spacing: 0.14em; + text-transform: uppercase; + } + + .combat-placeholder h2 { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.6rem; + font-weight: 400; + } + + .combat-placeholder p { + margin: 0; + color: var(--ar-text-muted); + line-height: 1.55; + } + + .combat-placeholder__id { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + font-style: italic; + } + `, + ], +}) +export class CombatPlaceholderPageComponent { + private readonly route = inject(ActivatedRoute); + + protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId'); +} diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html new file mode 100644 index 0000000..77b1d87 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html @@ -0,0 +1,67 @@ +
+ @if (worldStore.currentLocation(); as location) { + @if (!location.huntingEnabled) { +
+

Keine Jagd verfügbar

+

+ Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres + Gebiet, um nach Gegnern zu suchen. +

+ +
+ } @else if (huntingStore.currentHunt(); as hunt) { +
+

{{ location.name }} — Begegnungen

+ +
+ @for (encounter of huntingStore.encounters(); track encounter.id) { + + } +
+ +
+ + +
+
+ } @else { +
+

{{ location.name }}

+

{{ location.description }}

+

+ Durchsuche die Umgebung nach Spuren und Gegnern, bevor du dich in den Kampf wagst. +

+ +
+ } + + @if (huntingStore.loading()) { +

Du suchst nach Spuren...

+ } + } @else { +
+

Jagdgebiet wird vorbereitet.

+
+ } + + @if (huntingStore.error(); as error) { + + } +
diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss new file mode 100644 index 0000000..070dff9 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss @@ -0,0 +1,127 @@ +:host { + display: block; + min-block-size: 100%; +} + +.hunt-page { + position: relative; + min-block-size: 100%; +} + +.hunt-page__unavailable, +.hunt-page__ready, +.hunt-page__results { + padding: var(--ar-space-5); + border: 1px solid var(--ar-border); + background: var(--ar-panel); + box-shadow: var(--ar-shadow-raised); +} + +.hunt-page__unavailable h2, +.hunt-page__ready h2 { + margin: 0 0 var(--ar-space-3); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.6rem; + font-weight: 400; +} + +.hunt-page__unavailable p, +.hunt-page__ready p { + margin: 0 0 var(--ar-space-4); + color: var(--ar-text-muted); + line-height: 1.55; +} + +.hunt-page__hint { + font-style: italic; +} + +.hunt-page__results-heading { + margin: 0 0 var(--ar-space-4); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.3rem; +} + +.hunt-page__encounters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--ar-space-4); + margin-block-end: var(--ar-space-5); +} + +.hunt-page__actions { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-3); +} + +.hunt-page__unavailable button, +.hunt-page__ready button, +.hunt-page__actions button { + padding: var(--ar-space-2) var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: linear-gradient(180deg, #263b4b, #17232d); + cursor: pointer; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; +} + +.hunt-page__unavailable button:hover, +.hunt-page__ready button:not(:disabled):hover, +.hunt-page__actions button:not(:disabled):hover { + border-color: #d6b26b; + background: linear-gradient(180deg, #315067, #1a2c3a); +} + +.hunt-page__ready button:disabled, +.hunt-page__actions button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.hunt-page__loading, +.hunt-page__error, +.hunt-page__empty { + margin: var(--ar-space-4) 0 0; + border: 1px solid var(--ar-border); + background: var(--ar-panel); + box-shadow: var(--ar-shadow-raised); +} + +.hunt-page__loading, +.hunt-page__empty { + padding: var(--ar-space-4); + color: var(--ar-text-muted); +} + +.hunt-page__error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ar-space-4); + padding: var(--ar-space-3) var(--ar-space-4); + border-color: var(--ar-danger); +} + +.hunt-page__error p { + margin: 0; +} + +.hunt-page__error button { + flex: 0 0 auto; + padding: var(--ar-space-2) var(--ar-space-3); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: #1a2023; + cursor: pointer; +} + +@media (width < 720px) { + .hunt-page__encounters { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts new file mode 100644 index 0000000..039ffb0 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -0,0 +1,222 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; +import { WorldStore } from '../../world/world.store'; +import { HuntingStore } from '../hunting.store'; +import { HuntPageComponent } from './hunt-page.component'; + +const southGate: CurrentLocationResponse = { + id: 'south-gate-id', + key: 'south-gate', + name: 'Südtor von Graufurt', + description: 'Der letzte sichere Schritt vor den Aschenfeldern.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/images/backgrounds/Suedtor.png', + connections: [], + possibleMonsters: [], +}; + +const burnedRoad: CurrentLocationResponse = { + ...southGate, + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.', + isSafe: false, + huntingEnabled: true, + artworkPath: '/images/backgrounds/Aschestrasse.png', + possibleMonsters: ['Aschenratte', 'Straßenräuber'], + connections: [], +}; + +const threeEncounterHunt: HuntResult = { + id: 'hunt-id', + location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' }, + encounters: [ + { + id: 'encounter-1', + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + artworkPath: '/images/enemies/AshRat.png', + }, + dangerRating: 'WEAK', + }, + { + id: 'encounter-2', + monster: { + key: 'road-bandit', + name: 'Straßenräuber', + level: 3, + artworkPath: '/images/enemies/RoadBandit.png', + }, + dangerRating: 'MATCH', + }, + { + id: 'encounter-3', + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + artworkPath: '/images/enemies/AshRat.png', + }, + dangerRating: 'WEAK', + }, + ], +}; + +describe('HuntPageComponent', () => { + let worldStore: { + currentLocation: ReturnType>; + }; + let huntingStore: { + currentHunt: ReturnType>; + loading: ReturnType>; + error: ReturnType>; + encounters: () => HuntResult['encounters']; + startHunt: ReturnType; + refreshHunt: ReturnType; + selectEncounter: ReturnType; + }; + let router: Router; + + async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) { + worldStore = { currentLocation: signal(location) }; + const currentHunt = signal(hunt); + huntingStore = { + currentHunt, + loading: signal(false), + error: signal(null), + encounters: () => currentHunt()?.encounters ?? [], + startHunt: vi.fn(() => Promise.resolve()), + refreshHunt: vi.fn(() => Promise.resolve()), + selectEncounter: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [HuntPageComponent], + providers: [ + provideRouter([]), + { provide: WorldStore, useValue: worldStore }, + { provide: HuntingStore, useValue: huntingStore }, + ], + }).compileComponents(); + + router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + + const fixture = TestBed.createComponent(HuntPageComponent); + fixture.detectChanges(); + return fixture; + } + + it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => { + const fixture = await setup(southGate); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Keine Jagd verfügbar'); + expect(element.textContent).toContain( + 'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.', + ); + expect( + Array.from(element.querySelectorAll('button')).some( + (button) => button.textContent?.trim() === 'Jagd beginnen', + ), + ).toBe(false); + + const toWorldButton = element.querySelector('[data-hunt-to-world]'); + expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte'); + toWorldButton?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/world']); + }); + + it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => { + const fixture = await setup(burnedRoad); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-hunt-start]')?.click(); + + expect(huntingStore.startHunt).toHaveBeenCalledOnce(); + }); + + it('renders 3 encounter cards, duplicates included, with the correct data', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + const element = fixture.nativeElement as HTMLElement; + + const cards = element.querySelectorAll('app-encounter-card'); + expect(cards.length).toBe(3); + expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/); + expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2); + expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1); + expect(element.textContent).toContain('Stufe 1'); + expect(element.textContent).toContain('Stufe 3'); + }); + + it('calls refreshHunt when Neu suchen is clicked', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-hunt-refresh]')?.click(); + + expect(huntingStore.refreshHunt).toHaveBeenCalledOnce(); + }); + + it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + const element = fixture.nativeElement as HTMLElement; + + const attackButtons = Array.from(element.querySelectorAll('button')).filter( + (button) => button.textContent?.trim() === 'Angreifen', + ); + expect(attackButtons.length).toBe(3); + + attackButtons[1].click(); + + expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2'); + expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], { + queryParams: { encounterId: 'encounter-2' }, + }); + expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], { + queryParams: { encounterId: 'road-bandit' }, + }); + }); + + it('does not trigger a hunt automatically on page entry', async () => { + await setup(burnedRoad); + + expect(huntingStore.startHunt).not.toHaveBeenCalled(); + }); + + it('shows a loading state and disables the triggering action', async () => { + const fixture = await setup(burnedRoad); + huntingStore.loading.set(true); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).toContain('Du suchst nach Spuren...'); + expect(element.querySelector('[data-hunt-start]')?.disabled).toBe(true); + }); + + it('displays a hunting error and retries via startHunt when there is no current hunt', async () => { + const fixture = await setup(burnedRoad); + huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.'); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelector('[role="alert"]')?.textContent).toContain( + 'An diesem Ort gibt es keine Jagdgebiete.', + ); + element.querySelector('[data-hunt-retry]')?.click(); + + expect(huntingStore.startHunt).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts new file mode 100644 index 0000000..bda1567 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { EncounterCardComponent } from '../encounter-card/encounter-card.component'; +import { HuntingStore } from '../hunting.store'; +import { WorldStore } from '../../world/world.store'; + +@Component({ + selector: 'app-hunt-page', + imports: [EncounterCardComponent], + templateUrl: './hunt-page.component.html', + styleUrl: './hunt-page.component.scss', +}) +export class HuntPageComponent { + protected readonly worldStore = inject(WorldStore); + protected readonly huntingStore = inject(HuntingStore); + private readonly router = inject(Router); + + protected startHunt(): void { + void this.huntingStore.startHunt(); + } + + protected refreshHunt(): void { + void this.huntingStore.refreshHunt(); + } + + protected retry(): void { + if (this.huntingStore.currentHunt() === null) { + void this.huntingStore.startHunt(); + } else { + void this.huntingStore.refreshHunt(); + } + } + + protected goToWorld(): void { + void this.router.navigate(['/world']); + } + + protected onAttack(encounterId: string): void { + this.huntingStore.selectEncounter(encounterId); + void this.router.navigate(['/combat/new'], { queryParams: { encounterId } }); + } +} diff --git a/apps/web/src/app/layout/context-panel/context-panel.component.html b/apps/web/src/app/layout/context-panel/context-panel.component.html index 762a7fb..7336831 100644 --- a/apps/web/src/app/layout/context-panel/context-panel.component.html +++ b/apps/web/src/app/layout/context-panel/context-panel.component.html @@ -30,6 +30,12 @@
Jagd
{{ location.huntingEnabled ? 'Jagd möglich' : 'Keine Jagd' }}
+ @if (location.huntingEnabled && location.possibleMonsters.length) { +
+
Mögliche Begegnungen
+
{{ location.possibleMonsters.join(', ') }}
+
+ } } @else { GEBIETSINFO diff --git a/apps/web/src/app/layout/context-panel/context-panel.component.spec.ts b/apps/web/src/app/layout/context-panel/context-panel.component.spec.ts index 878ad53..d3b4566 100644 --- a/apps/web/src/app/layout/context-panel/context-panel.component.spec.ts +++ b/apps/web/src/app/layout/context-panel/context-panel.component.spec.ts @@ -3,6 +3,33 @@ import { TestBed } from '@angular/core/testing'; import { WorldStore } from '../../features/world/world.store'; import { ContextPanelComponent } from './context-panel.component'; +const southGate = { + id: 'south-gate-id', + key: 'south-gate', + name: 'Südtor von Graufurt', + description: 'Der letzte sichere Schritt vor den Aschenfeldern.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/images/backgrounds/Suedtor.png', + connections: [], + possibleMonsters: [], +}; + +const burnedRoad = { + ...southGate, + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + isSafe: false, + huntingEnabled: true, + artworkPath: '/images/backgrounds/Aschestrasse.png', + possibleMonsters: ['Aschenratte', 'Straßenräuber'], +}; + describe('ContextPanelComponent', () => { it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => { await TestBed.configureTestingModule({ @@ -11,20 +38,7 @@ describe('ContextPanelComponent', () => { { provide: WorldStore, useValue: { - currentLocation: signal({ - id: 'south-gate-id', - key: 'south-gate', - name: 'Südtor von Graufurt', - description: 'Der letzte sichere Schritt vor den Aschenfeldern.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 1, - dangerLevel: 0, - isSafe: true, - huntingEnabled: false, - artworkPath: '/images/backgrounds/Suedtor.png', - connections: [], - }), + currentLocation: signal(southGate), selectedConnection: signal(null), }, }, @@ -42,4 +56,48 @@ describe('ContextPanelComponent', () => { '/images/backgrounds/Suedtor.png', ); }); + + it('hides "Mögliche Begegnungen" when the location has no hunting', async () => { + await TestBed.configureTestingModule({ + imports: [ContextPanelComponent], + providers: [ + { + provide: WorldStore, + useValue: { + currentLocation: signal(southGate), + selectedConnection: signal(null), + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ContextPanelComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).not.toContain('Mögliche Begegnungen'); + }); + + it('lists possible monsters as plain text when hunting is enabled', async () => { + await TestBed.configureTestingModule({ + imports: [ContextPanelComponent], + providers: [ + { + provide: WorldStore, + useValue: { + currentLocation: signal(burnedRoad), + selectedConnection: signal(null), + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ContextPanelComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).toContain('Mögliche Begegnungen'); + expect(element.textContent).toContain('Aschenratte, Straßenräuber'); + expect(element.textContent).not.toMatch(/\d+\s?%/); + }); }); diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index 2a34f08..563ea58 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -1,10 +1,12 @@