From 88f4be45c41cfa23f759af70fcc1f16e5fa96091 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:19:47 -0400 Subject: [PATCH] feat(quests): track PokeCoin quest rewards on a Poracle that can store them PoracleNG 5.2.0 widened its quest validRewardTypes allowlist to include reward type 8. The quest dialogs gain a sixth reward tab that behaves like stardust -- a minimum amount and no item selector, because Poracle matches this reward on the amount alone and reads the floor from `reward` rather than `amount`. Gated on the server version, which is the exception to the otherwise preferable "gate on the applied migration number" rule: pokecoins added no column and no config flag, and /health carries no capability key for it, so the version string is the only signal there is. QuestPokecoinCapabilityService follows the shape of SummaryCapabilityService and MuteCapabilityService and fails closed. Verified by calling both dev servers rather than reading upstream source: 5.1.0 answers 400 "Unrecognised reward_type value"; 5.2.1 stores the row and describes it as "50 or more pokecoins". The refusal lives in QuestService, not the controller, so quick-pick apply and profile import are covered -- both reach BulkCreateAsync without passing a quest action. Reads and deletes are deliberately ungated: a pokecoin rule set with the bot, or left behind by a downgrade, has to stay visible and removable, and a row nobody can see is a row nobody can delete. The tab is rendered last so the five existing tabs keep their indices whether it appears or not -- tabIndex is positional and the save switch reads it. --- .../ServiceCollectionExtensions.cs | 1 + .../Controllers/QuestController.cs | 18 +- .../src/app/core/services/quest.service.ts | 30 ++- .../quests/quest-add-dialog.component.html | 13 ++ .../quests/quest-add-dialog.component.spec.ts | 5 +- .../quests/quest-add-dialog.component.ts | 40 ++++ .../quests/quest-add-dialog.pokecoins.spec.ts | 139 ++++++++++++ .../quests/quest-edit-dialog.component.html | 8 +- .../quests/quest-edit-dialog.component.ts | 31 ++- .../modules/quests/quest-list.component.ts | 13 ++ .../ClientApp/src/assets/i18n/da.json | 5 + .../ClientApp/src/assets/i18n/de.json | 5 + .../ClientApp/src/assets/i18n/en.json | 5 + .../ClientApp/src/assets/i18n/es.json | 5 + .../ClientApp/src/assets/i18n/fr.json | 5 + .../ClientApp/src/assets/i18n/it.json | 5 + .../ClientApp/src/assets/i18n/nl.json | 5 + .../ClientApp/src/assets/i18n/pl.json | 5 + .../ClientApp/src/assets/i18n/pt-BR.json | 5 + .../ClientApp/src/assets/i18n/pt.json | 5 + .../ClientApp/src/assets/i18n/sv.json | 5 + CHANGELOG.md | 4 + .../IQuestPokecoinCapabilityService.cs | 14 ++ .../QuestRewardTypes.cs | 29 +++ .../QuestPokecoinCapabilityService.cs | 50 +++++ .../QuestService.cs | 46 +++- .../Controllers/QuestControllerTests.cs | 3 +- .../QuestPokecoinCapabilityServiceTests.cs | 96 ++++++++ .../Services/QuestPokecoinCapabilityTests.cs | 208 ++++++++++++++++++ .../Services/QuestRewardAmountTests.cs | 7 +- .../Services/QuestServiceTests.cs | 8 +- .../Services/TrackingFieldCoverageTests.cs | 7 +- .../UnmodelledFieldPreservationTests.cs | 7 +- .../TestDoubles/PokecoinCapabilityStub.cs | 22 ++ 34 files changed, 837 insertions(+), 17 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuestPokecoinCapabilityService.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/QuestRewardTypes.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Services/QuestPokecoinCapabilityService.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityServiceTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/TestDoubles/PokecoinCapabilityStub.cs diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index 363848d8..2167e956 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs index ab668801..4506a67d 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs @@ -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; + + /// + /// Which optional quest reward types the PoracleNG behind this install can actually store. + /// + /// + /// Degrades to pokecoins:false 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 + /// GET /api/summary-schedules/capability. This is presentation only: the real refusal lives + /// in QuestService, because quick-pick apply and profile import never pass through a dialog. + /// + [HttpGet("capability")] + public async Task GetCapability() => this.Ok(new + { + pokecoins = await this._pokecoinCapability.ArePokecoinRewardsSupportedAsync() + }); [HttpGet] public async Task GetAll() diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/quest.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/quest.service.ts index 9ed50a36..41945299 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/quest.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/quest.service.ts @@ -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 { return this.http.post(`${this.config.apiHost}/api/quests`, quest); } @@ -26,6 +41,17 @@ export class QuestService { return this.http.get(`${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(`${this.config.apiHost}/api/quests/capability`) + .pipe(catchError(() => of({ pokecoins: false }))) + .subscribe(res => this.pokecoinsSupported.set(res.pokecoins)); + } + update(uid: number, quest: QuestUpdate): Observable { return this.http.put(`${this.config.apiHost}/api/quests/${uid}`, quest); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html index ad315e84..55ab60b0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html @@ -73,6 +73,19 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

+ + @if (supportsPokecoins()) { + +
+ + {{ 'QUESTS.MIN_POKECOINS' | translate }} + + {{ 'QUESTS.MIN_POKECOINS_HINT' | translate }} + +
+
+ } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts index 8f4dcd1d..8e911bad2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts @@ -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({ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts index 1ea87a96..8dbab130 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts @@ -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 }[]>([]); @@ -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; } @@ -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 @@ -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(); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts new file mode 100644 index 00000000..4d83041f --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts @@ -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; + 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); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html index aa12ae0c..e421c34f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html @@ -31,11 +31,11 @@

{{ getTitle() }}

{{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} } - @if (isStardust) { + @if (usesRewardSlot) { - {{ 'QUESTS.MIN_STARDUST' | translate }} - - {{ 'QUESTS.MIN_STARDUST_HINT' | translate }} + {{ (isPokecoins ? 'QUESTS.MIN_POKECOINS' : 'QUESTS.MIN_STARDUST') | translate }} + + {{ (isPokecoins ? 'QUESTS.MIN_POKECOINS_HINT' : 'QUESTS.MIN_STARDUST_HINT') | translate }} } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts index 841ae251..8b2ca893 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts @@ -29,6 +29,21 @@ const QUANTITY_REWARD_TYPES = new Set([2, 4, 12]); /** Stardust reads its floor from `reward`; `amount` is ignored for this type. */ const STARDUST = 3; +/** + * Pokecoins. Like stardust, PoracleNG matches on the amount alone and reads it from `reward` rather + * than `amount`, so the two share a control. + */ +const POKECOINS = 8; + +/** + * The reward types whose minimum lives in the `reward` slot. + * + * Editing a pokecoin rule is deliberately NOT gated on the server supporting pokecoins. The rule can + * exist already -- set with the bot, or left behind by a PoracleNG downgrade -- and a row nobody can + * see is a row nobody can delete. Only creating one is gated. + */ +const REWARD_SLOT_TYPES = new Set([STARDUST, POKECOINS]); + @Component({ imports: [ ReactiveFormsModule, @@ -69,7 +84,7 @@ export class QuestEditDialogComponent { // changing which reward it is about, so unlike the reward itself they are editable here. amount: [this.data.amount ?? 0], clean: [isAutoDelete(this.data.clean)], - stardust: [this.data.rewardType === STARDUST ? (this.data.reward ?? 0) : 0], + stardust: [REWARD_SLOT_TYPES.has(this.data.rewardType) ? (this.data.reward ?? 0) : 0], summary: [isSummary(this.data.clean)], template: [this.data.template ?? ''], }); @@ -77,6 +92,8 @@ export class QuestEditDialogComponent { /** Reward types that come in quantities, so "at least N" means something. */ readonly hasAmount = QUANTITY_REWARD_TYPES.has(this.data.rewardType); + readonly isPokecoins = this.data.rewardType === POKECOINS; + readonly isStardust = this.data.rewardType === STARDUST; readonly isWebhook = inject(AuthService).isImpersonating(); @@ -88,6 +105,9 @@ export class QuestEditDialogComponent { readonly summaryService = inject(SummaryScheduleService); + /** True when the minimum travels in `reward`: stardust and pokecoins both do. */ + readonly usesRewardSlot = REWARD_SLOT_TYPES.has(this.data.rewardType); + private get questPokemonId(): number { return this.data.pokemonId > 0 ? this.data.pokemonId : this.data.reward; } @@ -114,6 +134,8 @@ export class QuestEditDialogComponent { return this.i18n.instant('QUESTS.REWARD_ITEM'); case 3: return this.i18n.instant('QUESTS.STARDUST'); + case 8: + return this.i18n.instant('QUESTS.POKECOINS'); case 12: return this.i18n.instant('QUESTS.REWARD_MEGA_ENERGY'); case 4: @@ -142,6 +164,11 @@ export class QuestEditDialogComponent { ? this.i18n.instant('QUESTS.STARDUST_AMOUNT', { amount: this.data.reward }) : this.i18n.instant('QUESTS.STARDUST'); } + if (this.data.rewardType === 8) { + return this.data.reward > 0 + ? this.i18n.instant('QUESTS.POKECOINS_AMOUNT', { amount: this.data.reward }) + : this.i18n.instant('QUESTS.POKECOINS'); + } if (this.data.rewardType === 2) { return this.masterData.getItemName(this.data.reward); } @@ -168,7 +195,7 @@ export class QuestEditDialogComponent { clean: preserve(this.data.clean, AUTO_DELETE | SUMMARY, compose(!!values.clean, false, !!values.summary)), distance: scope.distance, pokemonId: this.data.pokemonId, - reward: this.isStardust ? (values.stardust ?? 0) : this.data.reward, + reward: this.usesRewardSlot ? (values.stardust ?? 0) : this.data.reward, rewardType: this.data.rewardType, shiny: this.data.shiny, template: values.template || '', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts index 2c7df962..c4a477b5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts @@ -269,6 +269,9 @@ export class QuestListComponent implements OnInit { // type it does not recognise gets. case 3: return '#FBC02D'; + // Pokecoins get their own gold rather than the grey fallback, for the same reason stardust did. + case 8: + return '#FFB300'; default: return '#9E9E9E'; } @@ -284,6 +287,8 @@ export class QuestListComponent implements OnInit { return this.i18n.instant('QUESTS.REWARD_MEGA_ENERGY'); case 4: return this.i18n.instant('QUESTS.REWARD_CANDY'); + case 8: + return this.i18n.instant('QUESTS.POKECOINS'); default: return this.i18n.instant('QUESTS.REWARD_TYPE_PREFIX', { type: rewardType }); } @@ -318,6 +323,7 @@ export class QuestListComponent implements OnInit { ngOnInit(): void { this.loadProfileAreas(); this.summaryService.loadCapability(); + this.questService.loadPokecoinCapability(); this.masterData .loadData() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -415,6 +421,13 @@ export class QuestListComponent implements OnInit { ? this.i18n.instant('QUESTS.STARDUST_AMOUNT', { amount: quest.reward }) : this.i18n.instant('QUESTS.STARDUST'); } + // Ungated on purpose: a pokecoin rule set with the bot, or left behind by a PoracleNG downgrade, + // still has to be readable and deletable here. Only creating one asks whether the server can. + if (quest.rewardType === 8) { + return quest.reward > 0 + ? this.i18n.instant('QUESTS.POKECOINS_AMOUNT', { amount: quest.reward }) + : this.i18n.instant('QUESTS.POKECOINS'); + } return this.getRewardTypeLabel(quest.rewardType); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index ce7da526..1c9cba91 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = et hvilket som helst antal", "MIN_STARDUST": "Mindste stjernestøv", "MIN_STARDUST_HINT": "0 = enhver stjernestøvsopgave", + "POKECOINS": "PokéMønter", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMønter", + "TAB_POKECOINS": "PokéMønter", + "MIN_POKECOINS": "Mindste antal PokéMønter", + "MIN_POKECOINS_HINT": "0 = enhver PokéMønt-opgave", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json index 89ad098e..a8f50350 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = beliebige Menge", "MIN_STARDUST": "Mindestens Sternenstaub", "MIN_STARDUST_HINT": "0 = jede Sternenstaub-Aufgabe", + "POKECOINS": "PokéMünzen", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMünzen", + "TAB_POKECOINS": "PokéMünzen", + "MIN_POKECOINS": "Mindestens PokéMünzen", + "MIN_POKECOINS_HINT": "0 = jede PokéMünzen-Aufgabe", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index 1ec06eda..54f5360c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = any amount", "MIN_STARDUST": "Minimum Stardust", "MIN_STARDUST_HINT": "0 = any stardust quest", + "POKECOINS": "PokéCoins", + "POKECOINS_AMOUNT": "{{amount}}+ PokéCoins", + "TAB_POKECOINS": "PokéCoins", + "MIN_POKECOINS": "Minimum PokéCoins", + "MIN_POKECOINS_HINT": "0 = any PokéCoin quest", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json index f52239ec..8e9bf38b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = cualquier cantidad", "MIN_STARDUST": "Polvo estelar mínimo", "MIN_STARDUST_HINT": "0 = cualquier tarea de polvo estelar", + "POKECOINS": "PokéMonedas", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMonedas", + "TAB_POKECOINS": "PokéMonedas", + "MIN_POKECOINS": "PokéMonedas mínimas", + "MIN_POKECOINS_HINT": "0 = cualquier tarea de PokéMonedas", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json index bbdec2c4..fb7c2146 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = toute quantité", "MIN_STARDUST": "Poussière étoilée minimum", "MIN_STARDUST_HINT": "0 = toute étude en poussière étoilée", + "POKECOINS": "Poképièces", + "POKECOINS_AMOUNT": "{{amount}}+ Poképièces", + "TAB_POKECOINS": "Poképièces", + "MIN_POKECOINS": "Poképièces minimum", + "MIN_POKECOINS_HINT": "0 = toute étude en Poképièces", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json index 47d18859..b1e6bd73 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = qualsiasi quantità", "MIN_STARDUST": "Polvere di stelle minima", "MIN_STARDUST_HINT": "0 = qualsiasi incarico con polvere di stelle", + "POKECOINS": "PokéMonete", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMonete", + "TAB_POKECOINS": "PokéMonete", + "MIN_POKECOINS": "PokéMonete minime", + "MIN_POKECOINS_HINT": "0 = qualsiasi incarico con PokéMonete", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json index 342078b7..5627aa9f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = elk aantal", "MIN_STARDUST": "Minimaal sterrenstof", "MIN_STARDUST_HINT": "0 = elke sterrenstof-opdracht", + "POKECOINS": "PokéMunten", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMunten", + "TAB_POKECOINS": "PokéMunten", + "MIN_POKECOINS": "Minimaal PokéMunten", + "MIN_POKECOINS_HINT": "0 = elke PokéMunten-opdracht", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json index 0b7b95ba..fbab79a9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = dowolna liczba", "MIN_STARDUST": "Minimalny gwiezdny pył", "MIN_STARDUST_HINT": "0 = każde zadanie z gwiezdnym pyłem", + "POKECOINS": "PokéMonety", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMonet", + "TAB_POKECOINS": "PokéMonety", + "MIN_POKECOINS": "Minimalna liczba PokéMonet", + "MIN_POKECOINS_HINT": "0 = każde zadanie z PokéMonetami", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json index dcef2829..c90e26ad 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = qualquer quantidade", "MIN_STARDUST": "Poeira estelar mínima", "MIN_STARDUST_HINT": "0 = qualquer tarefa de poeira estelar", + "POKECOINS": "PokéMoedas", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMoedas", + "TAB_POKECOINS": "PokéMoedas", + "MIN_POKECOINS": "PokéMoedas mínimas", + "MIN_POKECOINS_HINT": "0 = qualquer tarefa de PokéMoedas", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json index bc742c6b..b7f32f8a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = qualquer quantidade", "MIN_STARDUST": "Pó estelar mínimo", "MIN_STARDUST_HINT": "0 = qualquer tarefa de pó estelar", + "POKECOINS": "PokéMoedas", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMoedas", + "TAB_POKECOINS": "PokéMoedas", + "MIN_POKECOINS": "PokéMoedas mínimas", + "MIN_POKECOINS_HINT": "0 = qualquer tarefa de PokéMoedas", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json index 6e928ca7..4aa721ec 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -550,6 +550,11 @@ "MIN_AMOUNT_HINT": "0 = valfritt antal", "MIN_STARDUST": "Minsta stjärnstoft", "MIN_STARDUST_HINT": "0 = alla stjärnstoftsuppdrag", + "POKECOINS": "PokéMynt", + "POKECOINS_AMOUNT": "{{amount}}+ PokéMynt", + "TAB_POKECOINS": "PokéMynt", + "MIN_POKECOINS": "Minsta antal PokéMynt", + "MIN_POKECOINS_HINT": "0 = alla PokéMynt-uppdrag", "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { diff --git a/CHANGELOG.md b/CHANGELOG.md index a42a262b..6056c0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Quest alarms can track PokéCoin rewards.** Poracle started accepting them in 5.2.0, so quest alarms gained a sixth reward tab that works the way Stardust does: a minimum amount and no item picker, because Poracle matches this reward on the amount alone. The tab appears only on a Poracle new enough to store it -- an older one refuses the reward type outright -- and a PokéCoin rule you already have, set with the bot or left behind by a downgrade, stays visible and deletable either way. + ### Fixed - **The PVP rank range is readable again on a dark-themed alarm card.** The band under a PVP alarm showed its league and nothing else, so the ranks you had set looked like they had been dropped. They were being drawn, in white, on a band that stays light in both themes. The league name gained some contrast on the way past ([#800](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/800)). diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuestPokecoinCapabilityService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuestPokecoinCapabilityService.cs new file mode 100644 index 00000000..a0a8351d --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuestPokecoinCapabilityService.cs @@ -0,0 +1,14 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Whether the PoracleNG this instance talks to can store pokecoin quest rewards (reward_type: 8). +/// +public interface IQuestPokecoinCapabilityService +{ + /// + /// True when the server is 5.2.0 or newer. Never throws -- an unreachable or unparseable server + /// answers false, so the tab stays hidden rather than offering a control whose every save would be + /// refused with 400 "Unrecognised reward_type value". + /// + Task ArePokecoinRewardsSupportedAsync(CancellationToken cancellationToken = default); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuestRewardTypes.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuestRewardTypes.cs new file mode 100644 index 00000000..56899c2a --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuestRewardTypes.cs @@ -0,0 +1,29 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The quest reward_type values PoracleNG accepts, as game-master proto ids. +/// +/// +/// These mirror PoracleNG's validRewardTypes allowlist. Everything here except +/// is accepted by every supported PoracleNG; pokecoins arrived in 5.2.0 and an +/// older server answers 400 "Unrecognised reward_type value" -- verified against 5.1.0 and 5.2.1 on the +/// dev host, which is why creating one is gated rather than simply offered. +/// +public static class QuestRewardTypes +{ + public const int Item = 2; + + /// Stardust. The minimum amount travels in reward, not amount. + public const int Stardust = 3; + + public const int Candy = 4; + + public const int Pokemon = 7; + + /// + /// Pokecoins. Like stardust, the minimum amount travels in reward. Requires PoracleNG 5.2.0. + /// + public const int Pokecoins = 8; + + public const int MegaEnergy = 12; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/QuestPokecoinCapabilityService.cs b/Core/Pgan.PoracleWebNet.Core.Services/QuestPokecoinCapabilityService.cs new file mode 100644 index 00000000..86644ea4 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/QuestPokecoinCapabilityService.cs @@ -0,0 +1,50 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Whether the upstream PoracleNG accepts reward_type: 8 on a quest rule, answered from its +/// reported version. +/// +/// +/// +/// The version string is the only signal available here, and this is the exception to the otherwise +/// preferable "gate on the applied migration number" rule. Pokecoins added no column and no config +/// flag: 5.2.0 widened PoracleNG's validRewardTypes allowlist and nothing else. The quest table +/// is unchanged, so cannot +/// tell the two versions apart, and the /health capability map carries no key for it either -- +/// 5.2.1's map is {buttons, snapshots, autocreate, tomlDts, buttonResponseObject, derivedDtsTypes}, +/// verified live. Consulting Supports("pokecoins") would switch the feature off permanently, +/// because absent means false by that map's own contract. +/// +/// +/// Verified by calling both dev servers: 5.1.0 answers 400 "Unrecognised reward_type value"; 5.2.1 +/// stores the row and describes it as "50 or more pokecoins". +/// +/// +/// Fails closed, matching : unreachable, unparseable, or a +/// locally built binary reporting 0.0.0 all answer false. No cache of its own -- +/// already caches the profile for five minutes. +/// +/// +public class QuestPokecoinCapabilityService(IPoracleServerProfileService serverProfile) : IQuestPokecoinCapabilityService +{ + /// The release that widened validRewardTypes to include pokecoins. + public static readonly Version MinimumVersion = new(5, 2, 0); + + private readonly IPoracleServerProfileService _serverProfile = serverProfile; + + public async Task ArePokecoinRewardsSupportedAsync(CancellationToken cancellationToken = default) + { + try + { + var profile = await this._serverProfile.GetAsync(cancellationToken); + + return profile.Reachable && profile.ParsedVersion is { } version && version >= MinimumVersion; + } + catch + { + return false; + } + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs b/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs index 572e8ef2..320a313f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs @@ -5,11 +5,17 @@ namespace Pgan.PoracleWebNet.Core.Services; -public class QuestService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IQuestService +public class QuestService( + IPoracleTrackingProxy proxy, + IFeatureGate featureGate, + IQuestPokecoinCapabilityService pokecoinCapability, + ILogger logger, + ITrackedUidRemapper uidRemapper) : IQuestService { private const string TrackingType = "quest"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly IQuestPokecoinCapabilityService _pokecoinCapability = pokecoinCapability; private readonly ILogger _logger = logger; private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; @@ -29,6 +35,7 @@ public async Task> GetByUserAsync(string userId, int profileN public async Task CreateAsync(string userId, Quest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Quests); + await this.EnsureRewardTypeSupportedAsync(model.RewardType); model.Id = userId; // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: @@ -49,6 +56,7 @@ await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( public async Task UpdateAsync(string userId, Quest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Quests); + await this.EnsureRewardTypeSupportedAsync(model.RewardType); var oldUid = model.Uid; var body = SerializeToElement(model); @@ -174,6 +182,13 @@ public async Task> BulkCreateAsync(string userId, IEnumerable await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Quests); var modelList = models.ToList(); + // Checked for every row, not just the first: profile import and quick-pick apply both arrive + // here with a heterogeneous batch, and PoracleNG refuses the whole POST if any row is bad. + foreach (var rewardType in modelList.Select(m => m.RewardType).Distinct()) + { + await this.EnsureRewardTypeSupportedAsync(rewardType); + } + foreach (var model in modelList) { model.Id = userId; @@ -190,6 +205,35 @@ public async Task> BulkCreateAsync(string userId, IEnumerable return modelList; } + /// + /// Refuses a reward type this PoracleNG cannot store, before anything is written. + /// + /// + /// Pokecoins is the only gated type. PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type + /// value", which reaches the user as a generic failure naming neither cause nor fix; this says which + /// version would be needed. It lives in the service rather than the controller so quick-pick apply + /// and profile import are covered too -- both reach BulkCreateAsync without passing a quest + /// action. See #565 for that shape. + /// + /// Reads and deletes are deliberately NOT gated: a pokecoin rule can exist already, set with the bot + /// or left behind by a downgrade, and a row nobody can see is a row nobody can remove. + /// + private async Task EnsureRewardTypeSupportedAsync(int rewardType) + { + if (rewardType != QuestRewardTypes.Pokecoins) + { + return; + } + + if (await this._pokecoinCapability.ArePokecoinRewardsSupportedAsync()) + { + return; + } + + throw new AlarmValidationException( + "This Poracle server does not support PokeCoin quest rewards. PoracleNG 5.2.0 or newer is required."); + } + private static List DeserializeItems(JsonElement json) => PoracleJsonHelper.DeserializeList(json); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/QuestControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/QuestControllerTests.cs index 6020aed6..ea0a870a 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/QuestControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/QuestControllerTests.cs @@ -3,6 +3,7 @@ using Pgan.PoracleWebNet.Api.Controllers; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Tests.TestDoubles; namespace Pgan.PoracleWebNet.Tests.Controllers; @@ -13,7 +14,7 @@ public class QuestControllerTests : ControllerTestBase public QuestControllerTests() { - this._sut = new QuestController(this._service.Object); + this._sut = new QuestController(this._service.Object, PokecoinCapabilityStub.Supported); SetupUser(this._sut); } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityServiceTests.cs new file mode 100644 index 00000000..901167e3 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityServiceTests.cs @@ -0,0 +1,96 @@ +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// The version is the only signal for pokecoins, so this is where that reading is pinned down. +/// +/// +/// 5.2.0 widened PoracleNG's validRewardTypes allowlist and changed nothing else: no column, no +/// config flag, no /health capability key. The two live servers confirm the boundary -- 5.1.0 +/// refuses reward type 8 with a 400 and 5.2.1 stores it. +/// +public class QuestPokecoinCapabilityServiceTests +{ + private readonly Mock _profiles = new(); + + [Theory] + [InlineData("5.2.1", true)] + [InlineData("5.2.0", true)] + [InlineData("6.0.0", true)] + [InlineData("5.1.0", false)] + [InlineData("5.0.9", false)] + public async Task TheVersionDecides(string version, bool expected) + { + this.Answers(new PoracleServerProfile + { + Version = version, + Reachable = true + }); + + Assert.Equal(expected, await this.Sut().ArePokecoinRewardsSupportedAsync()); + } + + /// A build with no version stamped reports 0.0.0, which says nothing about what it can store. + [Theory] + [InlineData("0.0.0")] + [InlineData("not-a-version")] + [InlineData("")] + [InlineData(null)] + public async Task AnUnreadableVersionFailsClosed(string? version) + { + this.Answers(new PoracleServerProfile + { + Version = version, + Reachable = true + }); + + Assert.False(await this.Sut().ArePokecoinRewardsSupportedAsync()); + } + + /// + /// An unreachable server is not a new one. Offering the tab here would hand the user a control every + /// save of which fails for a reason that has nothing to do with pokecoins. + /// + [Fact] + public async Task AnUnreachableServerFailsClosed() + { + this.Answers(PoracleServerProfile.Unknown(DateTimeOffset.UtcNow)); + + Assert.False(await this.Sut().ArePokecoinRewardsSupportedAsync()); + } + + /// + /// A version string carrying a build suffix still parses -- self-hosters stamp 5.2.1-rc1 and the + /// like, and treating those as unknown would switch the feature off for a server that has it. + /// + [Fact] + public async Task ATaggedReleaseStillCounts() + { + this.Answers(new PoracleServerProfile + { + Version = "5.2.1-rc1", + Reachable = true + }); + + Assert.True(await this.Sut().ArePokecoinRewardsSupportedAsync()); + } + + [Fact] + public async Task AThrowingProbeFailsClosedRatherThanPropagating() + { + this._profiles + .Setup(p => p.GetAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("down")); + + Assert.False(await this.Sut().ArePokecoinRewardsSupportedAsync()); + } + + private void Answers(PoracleServerProfile profile) => + this._profiles.Setup(p => p.GetAsync(It.IsAny())).ReturnsAsync(profile); + + private QuestPokecoinCapabilityService Sut() => new(this._profiles.Object); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityTests.cs new file mode 100644 index 00000000..35bde52c --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestPokecoinCapabilityTests.cs @@ -0,0 +1,208 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; +using Pgan.PoracleWebNet.Tests.TestDoubles; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Pokecoin quest rewards (reward_type: 8) reach PoracleNG only when it can store them. +/// +/// +/// Verified by calling both dev servers directly rather than by reading upstream source: 5.1.0 answers +/// 400 "Unrecognised reward_type value" and 5.2.1 stores the row. Every refusal below is paired with +/// the legitimate case that must keep working -- an old server still has to take stardust, and a new +/// one still has to take pokecoins -- because a gate that refused everything would pass a refusal-only +/// suite. +/// +public class QuestPokecoinCapabilityTests +{ + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly Mock _remapper = new(); + + public QuestPokecoinCapabilityTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.GetByUserAsync("quest", It.IsAny())).ReturnsAsync(EmptyArray()); + this._proxy.Setup(p => p.CreateAsync("quest", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([7], 0, 0, 1)); + this._remapper + .Setup(r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + [Fact] + public async Task CreateRefusesPokecoinsWhenTheServerCannotStoreThem() + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + var ex = await Assert.ThrowsAsync( + () => sut.CreateAsync("u1", Pokecoins())); + + Assert.Contains("5.2.0", ex.Message, StringComparison.Ordinal); + this._proxy.Verify( + p => p.CreateAsync("quest", It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateAcceptsPokecoinsWhenTheServerCanStoreThem() + { + var sut = this.Sut(PokecoinCapabilityStub.Supported); + + var result = await sut.CreateAsync("u1", Pokecoins()); + + Assert.Equal(7, result.Uid); + this._proxy.Verify( + p => p.CreateAsync("quest", "u1", It.IsAny()), Times.Once); + } + + /// The five reward types every supported PoracleNG takes must not be caught by the gate. + [Theory] + [InlineData(QuestRewardTypes.Item)] + [InlineData(QuestRewardTypes.Stardust)] + [InlineData(QuestRewardTypes.Candy)] + [InlineData(QuestRewardTypes.Pokemon)] + [InlineData(QuestRewardTypes.MegaEnergy)] + public async Task CreateStillAcceptsEveryUngatedRewardTypeOnAnOlderServer(int rewardType) + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + var result = await sut.CreateAsync("u1", new Quest + { + Reward = 500, + RewardType = rewardType + }); + + Assert.Equal(7, result.Uid); + } + + [Fact] + public async Task UpdateRefusesPokecoinsWhenTheServerCannotStoreThem() + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", Pokecoins())); + + this._proxy.Verify( + p => p.CreateAsync("quest", It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Bulk is the path quick-pick apply and profile import take, and neither passes a quest action, so + /// a controller-level gate would miss both. Every distinct reward type in the batch is checked, not + /// just the first: PoracleNG refuses the whole POST when any row is bad. + /// + [Fact] + public async Task BulkCreateRefusesABatchWhosePokecoinRowIsNotTheFirst() + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + await Assert.ThrowsAsync(() => sut.BulkCreateAsync("u1", [ + new Quest + { + Reward = 500, + RewardType = QuestRewardTypes.Stardust + }, + Pokecoins(), + ])); + + this._proxy.Verify( + p => p.CreateAsync("quest", It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task BulkCreateStillWritesABatchWithNoPokecoinRowOnAnOlderServer() + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + var results = await sut.BulkCreateAsync("u1", [ + new Quest + { + Reward = 500, + RewardType = QuestRewardTypes.Stardust + }, + new Quest + { + Reward = 25, + RewardType = QuestRewardTypes.Pokemon + }, + ]); + + Assert.Equal(2, results.Count()); + this._proxy.Verify( + p => p.CreateAsync("quest", "u1", It.IsAny()), Times.Once); + } + + /// + /// Reading is never gated. A pokecoin rule set with the bot, or left behind by a downgrade, has to + /// stay visible -- a row nobody can see is a row nobody can delete. + /// + [Fact] + public async Task AnExistingPokecoinRuleIsStillReadableOnAnOlderServer() + { + var stored = JsonSerializer.Deserialize( + """[{"uid":519,"id":"u1","reward_type":8,"reward":50}]"""); + this._proxy.Setup(p => p.GetByUserAsync("quest", "u1")).ReturnsAsync(stored); + + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + var quest = await sut.GetByUidAsync("u1", 519); + + Assert.NotNull(quest); + Assert.Equal(QuestRewardTypes.Pokecoins, quest.RewardType); + } + + /// Deleting one is not gated either, for the same reason. + [Fact] + public async Task AnExistingPokecoinRuleIsStillDeletableOnAnOlderServer() + { + var sut = this.Sut(PokecoinCapabilityStub.Unsupported); + + await sut.DeleteAsync("u1", 519); + + this._proxy.Verify(p => p.DeleteByUidAsync("quest", "u1", 519), Times.Once); + } + + [Fact] + public async Task TheControllerReportsWhetherPokecoinsAreAvailable() + { + var controller = new QuestController(new Mock().Object, PokecoinCapabilityStub.Supported); + + var result = Assert.IsType(await controller.GetCapability()); + + Assert.True((bool)result.Value!.GetType().GetProperty("pokecoins")!.GetValue(result.Value)!); + } + + [Fact] + public async Task TheControllerReportsPokecoinsUnavailableOnAnOlderServer() + { + var controller = new QuestController(new Mock().Object, PokecoinCapabilityStub.Unsupported); + + var result = Assert.IsType(await controller.GetCapability()); + + Assert.False((bool)result.Value!.GetType().GetProperty("pokecoins")!.GetValue(result.Value)!); + } + + private static Quest Pokecoins() => new() + { + // PoracleNG matches pokecoins on the amount alone and reads it from reward, as it does stardust. + Reward = 50, + RewardType = QuestRewardTypes.Pokecoins + }; + + private static JsonElement EmptyArray() => JsonSerializer.Deserialize("[]"); + + private QuestService Sut(IQuestPokecoinCapabilityService capability) => new( + this._proxy.Object, + this._featureGate.Object, + capability, + NullLogger.Instance, + this._remapper.Object); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs index e8638578..911123f7 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs @@ -5,6 +5,7 @@ using Pgan.PoracleWebNet.Core.Mappings; using Pgan.PoracleWebNet.Core.Models; using Pgan.PoracleWebNet.Core.Services; +using Pgan.PoracleWebNet.Tests.TestDoubles; namespace Pgan.PoracleWebNet.Tests.Services; @@ -41,7 +42,11 @@ public QuestRewardAmountTests() private async Task WriteAsync(QuestCreate create) { await new QuestService( - this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + this._proxy.Object, + this._featureGate.Object, + PokecoinCapabilityStub.Supported, + NullLogger.Instance, + this._remapper.Object) .CreateAsync("u1", create.ToQuest()); return this._sent.ValueKind == JsonValueKind.Array ? this._sent.EnumerateArray().First() : this._sent; diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs index 3c8c2214..03f24d68 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs @@ -4,6 +4,7 @@ using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; using Pgan.PoracleWebNet.Core.Services; +using Pgan.PoracleWebNet.Tests.TestDoubles; namespace Pgan.PoracleWebNet.Tests.Services; @@ -22,7 +23,12 @@ public class QuestServiceTests public QuestServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new QuestService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); + this._sut = new QuestService( + this._proxy.Object, + this._featureGate.Object, + PokecoinCapabilityStub.Supported, + NullLogger.Instance, + this._uidRemapper.Object); } [Fact] diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs index 5aef3304..e47f7ed3 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs @@ -5,6 +5,7 @@ using Pgan.PoracleWebNet.Core.Mappings; using Pgan.PoracleWebNet.Core.Models; using Pgan.PoracleWebNet.Core.Services; +using Pgan.PoracleWebNet.Tests.TestDoubles; namespace Pgan.PoracleWebNet.Tests.Services; @@ -155,7 +156,11 @@ private async Task> WrittenColumnsAsync(string type) this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) .CreateAsync("u1", new EggCreate { Level = 5 }.ToEgg()), "quest" => new QuestService( - this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + this._proxy.Object, + this._featureGate.Object, + PokecoinCapabilityStub.Supported, + NullLogger.Instance, + this._remapper.Object) .CreateAsync("u1", new QuestCreate { Reward = 25, RewardType = 7 }.ToQuest()), "invasion" => new InvasionService( this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs index 03b5a62e..2526fe1b 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs @@ -4,6 +4,7 @@ using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; using Pgan.PoracleWebNet.Core.Services; +using Pgan.PoracleWebNet.Tests.TestDoubles; namespace Pgan.PoracleWebNet.Tests.Services; @@ -284,7 +285,11 @@ private JsonElement OnlyRowSent() "egg" => new EggService( this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), "quest" => new QuestService( - this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + this._proxy.Object, + this._featureGate.Object, + PokecoinCapabilityStub.Supported, + NullLogger.Instance, + this._remapper.Object), "invasion" => new InvasionService( this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), "lure" => new LureService( diff --git a/Tests/Pgan.PoracleWebNet.Tests/TestDoubles/PokecoinCapabilityStub.cs b/Tests/Pgan.PoracleWebNet.Tests/TestDoubles/PokecoinCapabilityStub.cs new file mode 100644 index 00000000..925d450e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/TestDoubles/PokecoinCapabilityStub.cs @@ -0,0 +1,22 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Tests.TestDoubles; + +/// +/// A pokecoin capability that answers whatever the test needs, without a live PoracleNG behind it. +/// +/// +/// is the default for every test that is not about pokecoins at all: it keeps +/// the reward types that work on every server working, which is exactly what a gate must not break. +/// +public sealed class PokecoinCapabilityStub(bool supported) : IQuestPokecoinCapabilityService +{ + /// A PoracleNG 5.2.0 or newer. + public static PokecoinCapabilityStub Supported => new(true); + + /// A PoracleNG 5.1.0, or one that could not be reached. + public static PokecoinCapabilityStub Unsupported => new(false); + + public Task ArePokecoinRewardsSupportedAsync(CancellationToken cancellationToken = default) => + Task.FromResult(supported); +}