feat(shops): sell loot bags, honour bypass conditions, explain locks
This commit is contained in:
@@ -1,13 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { GameConditionType } from '../conditions/game-condition.types';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import {
|
||||
describeBagEffect,
|
||||
describeItemEffect,
|
||||
describeRequirement,
|
||||
ShopOfferRequirementDto,
|
||||
} from './offer-presentation';
|
||||
import {
|
||||
characterNotFound,
|
||||
merchantReputationTooLow,
|
||||
shopBagAlreadyOwned,
|
||||
shopDisabled,
|
||||
shopInsufficientSilver,
|
||||
shopInvalidQuantity,
|
||||
@@ -26,12 +37,34 @@ export interface ShopOfferDto {
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
/** What buying it does: weapon stats, or a bag's capacity (slice §8). */
|
||||
effectSummary: string | null;
|
||||
/** Why it is locked, and how close the player is (slice §5). Empty when open. */
|
||||
requirements: ShopOfferRequirementDto[];
|
||||
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
||||
unlocked: boolean;
|
||||
/** True when the character simply cannot afford an otherwise open offer. */
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an offer sells, flattened out of whichever target it points at.
|
||||
*
|
||||
* Both shapes reduce to a key, a name, an icon and an effect, which is all the
|
||||
* presentation layer needs -- and `kind` plus `definitionId` is all the grant
|
||||
* needs, so the branch on offer kind lives in one place instead of being spread
|
||||
* across the view and the purchase path.
|
||||
*/
|
||||
interface OfferTarget {
|
||||
kind: 'item' | 'bag';
|
||||
definitionId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconPath: string;
|
||||
effectSummary: string | null;
|
||||
}
|
||||
|
||||
export interface ShopViewDto {
|
||||
shopKey: string;
|
||||
shopName: string;
|
||||
@@ -79,31 +112,53 @@ export class ShopService {
|
||||
|
||||
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
// One read for the whole view: every reputation requirement needs a
|
||||
// display name, and offers commonly gate on the same faction.
|
||||
const factionNames = new Map(
|
||||
(await this.dataSource.getRepository(ReputationFaction).find()).map(
|
||||
(faction) => [faction.key, faction.name],
|
||||
),
|
||||
);
|
||||
|
||||
const context = { characterId, npcId };
|
||||
const view: ShopOfferDto[] = [];
|
||||
for (const offer of offers) {
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId },
|
||||
const target = this.resolveTarget(offer);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const gate = await this.evaluateGate(context, offer);
|
||||
// Described even when open, so the UI can show a requirement the player
|
||||
// has already met rather than having it vanish on unlock. Only
|
||||
// `conditions` are described: a bypass is content's private exception,
|
||||
// not something the player is told to go and satisfy.
|
||||
const outcomes = await this.conditions.describe(
|
||||
context,
|
||||
offer.conditions,
|
||||
);
|
||||
const requirements = outcomes
|
||||
.map((outcome) => describeRequirement(outcome, factionNames))
|
||||
.filter(
|
||||
(requirement): requirement is ShopOfferRequirementDto =>
|
||||
requirement !== null,
|
||||
);
|
||||
|
||||
// TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable
|
||||
// when the offer table gained a loot-bag target alongside the item one.
|
||||
// Every offer sold an item until Task 4 adds bag offers here, so the
|
||||
// assertion is safe for now -- Task 4 replaces it with a real branch on
|
||||
// offer kind.
|
||||
view.push({
|
||||
itemKey: offer.itemDefinition!.key,
|
||||
itemName: offer.itemDefinition!.name,
|
||||
itemDescription: offer.itemDefinition!.description,
|
||||
iconPath: offer.itemDefinition!.iconPath,
|
||||
itemKey: target.key,
|
||||
itemName: target.name,
|
||||
itemDescription: target.description,
|
||||
iconPath: target.iconPath,
|
||||
currencyType: offer.currencyType,
|
||||
price: offer.price,
|
||||
quantity: offer.quantity,
|
||||
unlocked,
|
||||
effectSummary: target.effectSummary,
|
||||
requirements,
|
||||
unlocked: gate.open,
|
||||
affordable: character.silver >= offer.price,
|
||||
});
|
||||
}
|
||||
@@ -146,51 +201,80 @@ export class ShopService {
|
||||
}
|
||||
|
||||
// Matched on the joined definition's business key: the offer table is
|
||||
// keyed by item definition id, while the request carries the stable key.
|
||||
// keyed by definition id, while the request carries the stable key.
|
||||
const offers = await manager.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||
});
|
||||
const match = offers.find(
|
||||
(candidate) => candidate.itemDefinition!.key === itemKey,
|
||||
);
|
||||
let match: ShopOffer | undefined;
|
||||
let target: OfferTarget | undefined;
|
||||
for (const candidate of offers) {
|
||||
const candidateTarget = this.resolveTarget(candidate);
|
||||
if (candidateTarget?.key === itemKey) {
|
||||
match = candidate;
|
||||
target = candidateTarget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
if (!match || !target) {
|
||||
throw shopOfferNotFound();
|
||||
}
|
||||
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
const gate = await this.evaluateGate(
|
||||
{ characterId, npcId },
|
||||
match.conditions,
|
||||
match,
|
||||
manager,
|
||||
);
|
||||
if (!unlocked) {
|
||||
throw shopOfferLocked();
|
||||
if (!gate.open) {
|
||||
throw gate.reputationBlocked
|
||||
? merchantReputationTooLow()
|
||||
: shopOfferLocked();
|
||||
}
|
||||
|
||||
if (!match.repeatable && quantity > 1) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
// A bag is one object, not a stack: buying two grants nothing extra.
|
||||
if (target.kind === 'bag' && quantity > 1) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
|
||||
const silverSpent = match.price * quantity;
|
||||
if (character.silver < silverSpent) {
|
||||
throw shopInsufficientSilver();
|
||||
}
|
||||
|
||||
// Checked before the debit: the transaction would roll the Silver back
|
||||
// anyway, but failing on the cheap read keeps the error honest about
|
||||
// what went wrong.
|
||||
if (target.kind === 'bag') {
|
||||
const owned = await manager.getRepository(CharacterLootBag).findOne({
|
||||
where: { characterId, lootBagDefinitionId: target.definitionId },
|
||||
});
|
||||
if (owned) {
|
||||
throw shopBagAlreadyOwned();
|
||||
}
|
||||
}
|
||||
|
||||
character.silver -= silverSpent;
|
||||
await characters.save(character);
|
||||
|
||||
await this.grantItem(
|
||||
manager,
|
||||
characterId,
|
||||
match.itemDefinitionId!,
|
||||
match.quantity * quantity,
|
||||
);
|
||||
if (target.kind === 'bag') {
|
||||
await this.grantLootBag(manager, characterId, target.definitionId);
|
||||
} else {
|
||||
await this.grantItem(
|
||||
manager,
|
||||
characterId,
|
||||
target.definitionId,
|
||||
match.quantity * quantity,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
shopKey: shop.key,
|
||||
itemKey,
|
||||
itemName: match.itemDefinition!.name,
|
||||
itemName: target.name,
|
||||
quantity: match.quantity * quantity,
|
||||
silverSpent,
|
||||
silverBalance: character.silver,
|
||||
@@ -198,6 +282,110 @@ export class ShopService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What an offer sells, whichever kind of thing that is.
|
||||
*
|
||||
* The two targets are mutually exclusive by `CHK_shop_offers_single_target`,
|
||||
* so resolving them here lets the view and the purchase path agree on what an
|
||||
* offer *is* without either of them branching on offer kind itself.
|
||||
*/
|
||||
private resolveTarget(offer: ShopOffer): OfferTarget | null {
|
||||
if (offer.itemDefinition) {
|
||||
return {
|
||||
kind: 'item',
|
||||
definitionId: offer.itemDefinition.id,
|
||||
key: offer.itemDefinition.key,
|
||||
name: offer.itemDefinition.name,
|
||||
description: offer.itemDefinition.description,
|
||||
iconPath: offer.itemDefinition.iconPath,
|
||||
effectSummary: describeItemEffect(offer.itemDefinition),
|
||||
};
|
||||
}
|
||||
if (offer.lootBagDefinition) {
|
||||
return {
|
||||
kind: 'bag',
|
||||
definitionId: offer.lootBagDefinition.id,
|
||||
key: offer.lootBagDefinition.key,
|
||||
name: offer.lootBagDefinition.name,
|
||||
// A bag definition carries no flavour text of its own; the capacity
|
||||
// line is the honest description of what it is.
|
||||
description: describeBagEffect(offer.lootBagDefinition),
|
||||
iconPath: offer.lootBagDefinition.iconPath,
|
||||
effectSummary: describeBagEffect(offer.lootBagDefinition),
|
||||
};
|
||||
}
|
||||
// CHK_shop_offers_single_target makes this unreachable through the
|
||||
// database. Skipping the row beats rendering an offer that sells nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an offer is open, and why not when it is shut.
|
||||
*
|
||||
* `conditions` OR `bypassConditions` -- the whole exception model (slice §7).
|
||||
* A referral does not lower the requirement; it provides a second, narrower
|
||||
* door that content opens deliberately.
|
||||
*/
|
||||
private async evaluateGate(
|
||||
context: { characterId: string; npcId: string },
|
||||
offer: ShopOffer,
|
||||
manager?: EntityManager,
|
||||
): Promise<{ open: boolean; reputationBlocked: boolean }> {
|
||||
if (await this.conditions.evaluate(context, offer.conditions, manager)) {
|
||||
return { open: true, reputationBlocked: false };
|
||||
}
|
||||
|
||||
const bypass = offer.bypassConditions ?? [];
|
||||
if (
|
||||
bypass.length > 0 &&
|
||||
(await this.conditions.evaluate(context, bypass, manager))
|
||||
) {
|
||||
return { open: true, reputationBlocked: false };
|
||||
}
|
||||
|
||||
// Which error to raise depends on what is actually short, so the player is
|
||||
// told to earn reputation only when reputation is the thing missing.
|
||||
const outcomes = await this.conditions.describe(
|
||||
context,
|
||||
offer.conditions,
|
||||
manager,
|
||||
);
|
||||
const reputationBlocked = outcomes.some(
|
||||
(outcome) =>
|
||||
!outcome.met &&
|
||||
outcome.condition.type === GameConditionType.REGION_REPUTATION,
|
||||
);
|
||||
|
||||
return { open: false, reputationBlocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands over a bag, once.
|
||||
*
|
||||
* A second copy of the same bag grants nothing -- only the roomiest active
|
||||
* bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is
|
||||
* refused rather than silently charged. The unique index on
|
||||
* (character_id, loot_bag_definition_id) is the real guarantee; this check
|
||||
* is what turns a constraint violation into an explainable domain error.
|
||||
*/
|
||||
private async grantLootBag(
|
||||
manager: { getRepository: DataSource['getRepository'] },
|
||||
characterId: string,
|
||||
lootBagDefinitionId: string,
|
||||
): Promise<void> {
|
||||
const bags = manager.getRepository(CharacterLootBag);
|
||||
const existing = await bags.findOne({
|
||||
where: { characterId, lootBagDefinitionId },
|
||||
});
|
||||
if (existing) {
|
||||
throw shopBagAlreadyOwned();
|
||||
}
|
||||
|
||||
await bags.save(
|
||||
bags.create({ characterId, lootBagDefinitionId, active: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to an existing stack or starts a new one.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user