Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv
services.AddScoped<IUserGeofenceService, UserGeofenceService>();
services.AddScoped<ISiteSettingService, SiteSettingService>();
services.AddScoped<ISummaryCapabilityService, SummaryCapabilityService>();
services.AddScoped<IQuestPokecoinCapabilityService, QuestPokecoinCapabilityService>();
services.AddScoped<IMuteCapabilityService, MuteCapabilityService>();
services.AddScoped<ICostumeCapabilityService, CostumeCapabilityService>();
services.AddScoped<IUpstreamFeatureFlagService, UpstreamFeatureFlagService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ namespace Pgan.PoracleWebNet.Api.Controllers;

[Route("api/quests")]
[RequireFeatureEnabled(DisableFeatureKeys.Quests)]
public class QuestController(IQuestService questService) : BaseApiController
public class QuestController(IQuestService questService, IQuestPokecoinCapabilityService pokecoinCapability) : BaseApiController
{
private readonly IQuestService _questService = questService;
private readonly IQuestPokecoinCapabilityService _pokecoinCapability = pokecoinCapability;

/// <summary>
/// Which optional quest reward types the PoracleNG behind this install can actually store.
/// </summary>
/// <remarks>
/// Degrades to <c>pokecoins:false</c> on any fault -- never 5xx -- so a transient outage hides the
/// tab rather than offering one every save of which would be refused. Mirrors the shape of
/// <c>GET /api/summary-schedules/capability</c>. This is presentation only: the real refusal lives
/// in <c>QuestService</c>, because quick-pick apply and profile import never pass through a dialog.
/// </remarks>
[HttpGet("capability")]
public async Task<IActionResult> GetCapability() => this.Ok(new
{
pokecoins = await this._pokecoinCapability.ArePokecoinRewardsSupportedAsync()
});

[HttpGet]
public async Task<IActionResult> GetAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';

import { ConfigService } from './config.service';
import { Quest, QuestCreate, QuestUpdate } from '../models';

interface QuestCapabilityResponse {
pokecoins: boolean;
}

@Injectable({ providedIn: 'root' })
export class QuestService {
private capabilityLoaded = false;
private readonly config = inject(ConfigService);

private readonly http = inject(HttpClient);

/**
* Whether this deployment's PoracleNG accepts pokecoin quest rewards (`reward_type: 8`).
*
* False until the server says otherwise, and false again on any fault: an old PoracleNG answers the
* write with 400 "Unrecognised reward_type value", so a tab offered optimistically would be a control
* nobody could use. Presentation only -- the API refuses the write independently.
*/
readonly pokecoinsSupported = signal(false);

create(quest: QuestCreate): Observable<Quest> {
return this.http.post<Quest>(`${this.config.apiHost}/api/quests`, quest);
}
Expand All @@ -26,6 +41,17 @@ export class QuestService {
return this.http.get<Quest[]>(`${this.config.apiHost}/api/quests`);
}

/** Reads the capability once per session. Cheap, and the answer only changes on a server upgrade. */
loadPokecoinCapability(): void {
if (this.capabilityLoaded) return;
this.capabilityLoaded = true;

this.http
.get<QuestCapabilityResponse>(`${this.config.apiHost}/api/quests/capability`)
.pipe(catchError(() => of({ pokecoins: false })))
.subscribe(res => this.pokecoinsSupported.set(res.pokecoins));
}

update(uid: number, quest: QuestUpdate): Observable<void> {
return this.http.put<void>(`${this.config.apiHost}/api/quests/${uid}`, quest);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ <h2 mat-dialog-title>{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}</h2>
</mat-form-field>
</div>
</mat-tab>
<!-- Last on purpose: tabIndex is positional and the save switch reads it, so the tab that may
or may not render has to be the one that cannot shift the others. Needs PoracleNG 5.2.0. -->
@if (supportsPokecoins()) {
<mat-tab [label]="'QUESTS.TAB_POKECOINS' | translate">
<div class="tab-content">
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'QUESTS.MIN_POKECOINS' | translate }}</mat-label>
<input matInput type="number" min="0" step="1" [formControl]="pokecoinsForm.controls.reward" />
<mat-hint>{{ 'QUESTS.MIN_POKECOINS_HINT' | translate }}</mat-hint>
</mat-form-field>
</div>
</mat-tab>
}
</mat-tab-group>
</div>
</mat-tab>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import { QuestService } from '../../core/services/quest.service';
describe('QuestAddDialogComponent', () => {
let component: QuestAddDialogComponent;
let dialogRef: { close: jest.Mock };
let questService: { create: jest.Mock };
let questService: { create: jest.Mock; pokecoinsSupported: () => boolean };

function setup() {
dialogRef = { close: jest.fn() };
questService = { create: jest.fn().mockReturnValue(of({} as Quest)) };
// Pokecoins off: this suite is about the five tabs every PoracleNG has.
questService = { create: jest.fn().mockReturnValue(of({} as Quest)), pokecoinsSupported: () => false };

TestBed.resetTestingModule();
TestBed.configureTestingModule({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ export class QuestAddDialogComponent {
amount: [0],
});

/**
* Pokecoins mirror stardust exactly: no selector, and PoracleNG reads the minimum from `reward`
* rather than `amount`. Only offered when the server can store reward_type 8 -- see
* {@link supportsPokecoins}.
*/
pokecoinsForm = this.fb.group({
reward: [0],
});

/** Quest-relevant items (balls, berries, potions, revives, TMs, etc.) */
readonly questItems = signal<{ id: number; name: string }[]>([]);

Expand Down Expand Up @@ -148,6 +157,9 @@ export class QuestAddDialogComponent {
case 4:
// 0 is a rule in its own right: every stardust quest, whatever it pays.
return true;
case 5:
// Same as stardust: 0 means every pokecoin quest.
return true;
default:
return false;
}
Expand Down Expand Up @@ -269,6 +281,23 @@ export class QuestAddDialogComponent {
}),
);
break;
case 5:
creates.push(
this.questService.create({
overrideAreas: scope.overrideAreas,
overrideLocationLabel: scope.overrideLocationLabel,
amount: 0,
clean: cleanValue,
distance: scope.distance,
pokemonId: 0,
// Pokecoins carry their floor in reward, the same slot stardust uses.
reward: this.pokecoinsForm.controls.reward.value ?? 0,
rewardType: 8,
shiny: 0,
template: common.template || null,
}),
);
break;
}

// forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already
Expand Down Expand Up @@ -307,4 +336,15 @@ export class QuestAddDialogComponent {
},
});
}

/**
* Whether this PoracleNG can store pokecoin quest rewards at all.
*
* PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type value", so the tab is absent rather
* than present-and-failing. It is the last tab, which keeps every other tab's index stable whether it
* renders or not -- `tabIndex` is positional and the save switch reads it.
*/
supportsPokecoins(): boolean {
return this.questService.pokecoinsSupported();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';

import { QuestAddDialogComponent } from './quest-add-dialog.component';
import { Quest, QuestCreate } from '../../core/models';
import { AuthService } from '../../core/services/auth.service';
import { IconService } from '../../core/services/icon.service';
import { MasterDataService } from '../../core/services/masterdata.service';
import { PokemonAvailabilityService } from '../../core/services/pokemon-availability.service';
import { QuestService } from '../../core/services/quest.service';

/**
* Pokecoin quests are offered only when the PoracleNG behind this install can store them.
*
* PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type value" -- confirmed by POSTing to a live
* 5.1.0 -- so the tab is absent rather than present-and-failing. The pairing matters more than either
* half: a test that only proved the tab hides on an old server would pass just as well if it never
* rendered at all.
*/
describe('QuestAddDialogComponent — pokecoins capability', () => {
let component: QuestAddDialogComponent;
let fixture: ComponentFixture<QuestAddDialogComponent>;
let questService: { create: jest.Mock; loadPokecoinCapability: jest.Mock; pokecoinsSupported: () => boolean };

function setup(supported: boolean) {
questService = {
create: jest.fn().mockReturnValue(of({} as Quest)),
loadPokecoinCapability: jest.fn(),
pokecoinsSupported: () => supported,
};

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideTranslateService(),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{ provide: QuestService, useValue: questService },
{ provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } },
{
provide: MasterDataService,
useValue: {
getAllItems: () => [],
getAllPokemon: () => [],
getAllPokemon$: () => of([]),
getAllTypes: () => [],
getPokemonTypes: () => [],
loadData: () => of(void 0),
},
},
{ provide: PokemonAvailabilityService, useValue: { enabled: () => false, isAvailable: () => true, load: () => undefined } },
{ provide: IconService, useValue: { getItemUrl: () => '' } },
],
imports: [QuestAddDialogComponent],
});

fixture = TestBed.createComponent(QuestAddDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}

/** Reward tab labels, in order. `tabIndex` is positional, so the order is load-bearing. */
function rewardTabLabels(): string[] {
return Array.from(fixture.nativeElement.querySelectorAll('.reward-tabs .mat-mdc-tab .mdc-tab__text-label')).map(el =>
(el as HTMLElement).textContent!.trim(),
);
}

it('offers no pokecoins tab against a PoracleNG that would refuse it', () => {
setup(false);

expect(component.supportsPokecoins()).toBe(false);
expect(rewardTabLabels()).toHaveLength(5);
});

it('offers a pokecoins tab against a 5.2.0 or newer PoracleNG', () => {
setup(true);

expect(component.supportsPokecoins()).toBe(true);
expect(rewardTabLabels()).toHaveLength(6);
});

/**
* The five original tabs must keep their indices whether the sixth renders or not: `save()` reads
* `tabIndex` positionally, so a tab inserted anywhere but the end would file every reward under the
* wrong type.
*/
it('leaves the existing tab indices untouched when the pokecoins tab appears', () => {
setup(false);
const withoutPokecoins = rewardTabLabels();

setup(true);
const withPokecoins = rewardTabLabels();

expect(withPokecoins.slice(0, 5)).toEqual(withoutPokecoins);
});

it('creates a pokecoin quest with the minimum in the reward slot', () => {
setup(true);

component.tabIndex = 5;
component.pokecoinsForm.controls.reward.setValue(50);
component.save();

const create = questService.create.mock.calls[0][0] as QuestCreate;
expect(create.rewardType).toBe(8);
// PoracleNG matches pokecoins on the amount alone and reads it from `reward`, as it does stardust.
expect(create.reward).toBe(50);
expect(create.amount).toBe(0);
});

it('treats a minimum of 0 as every pokecoin quest, not as an incomplete form', () => {
setup(true);

component.tabIndex = 5;

expect(component.canSave()).toBe(true);
});

/** The stardust tab keeps working on a server that has no pokecoin support at all. */
it('still creates a stardust quest against an older PoracleNG', () => {
setup(false);

component.tabIndex = 4;
component.stardustForm.controls.reward.setValue(1000);
component.save();

const create = questService.create.mock.calls[0][0] as QuestCreate;
expect(create.rewardType).toBe(3);
expect(create.reward).toBe(1000);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ <h3>{{ getTitle() }}</h3>
<mat-hint>{{ 'QUESTS.MIN_AMOUNT_HINT' | translate }}</mat-hint>
</mat-form-field>
}
@if (isStardust) {
@if (usesRewardSlot) {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'QUESTS.MIN_STARDUST' | translate }}</mat-label>
<input matInput type="number" min="0" step="100" [formControl]="form.controls.stardust" />
<mat-hint>{{ 'QUESTS.MIN_STARDUST_HINT' | translate }}</mat-hint>
<mat-label>{{ (isPokecoins ? 'QUESTS.MIN_POKECOINS' : 'QUESTS.MIN_STARDUST') | translate }}</mat-label>
<input matInput type="number" min="0" [step]="isPokecoins ? 1 : 100" [formControl]="form.controls.stardust" />
<mat-hint>{{ (isPokecoins ? 'QUESTS.MIN_POKECOINS_HINT' : 'QUESTS.MIN_STARDUST_HINT') | translate }}</mat-hint>
</mat-form-field>
}
</div>
Expand Down
Loading
Loading