Adds IS NOT to the Part II parser and a resolution pass (negation, then
identity lock) that turns parsed rules into the rules in force; harness
gains activeRules() and hasProperty(). A leading NOT now breaks the
sentence instead of being ignored (carried from M6 review). Nouns say
which objects they name ("selects"), so the ALL Expansion Drop is one
line of data. Corrects lesson 06's NOT wording; harness.md M7 details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
// Assemble: "Get a random minion. Change half its text."
|
|
//
|
|
// Card text is a list of clauses, so changing half of it is list surgery: the base
|
|
// card's `text.slice(0, split)` (top) or `text.slice(split)` (bottom) is replaced by
|
|
// the same half of a donor card. Name, cost and stats stay the base's. This works
|
|
// only because a card's behaviour is its data: the new card needs no new code.
|
|
//
|
|
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
|
|
// definition: each card's data says where its top half ends.
|
|
|
|
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
|
|
import type { EntityId, PlayerId, World } from "./world";
|
|
|
|
export type Half = "top" | "bottom";
|
|
const HALVES: Half[] = ["top", "bottom"];
|
|
|
|
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
|
|
export class AssembleError extends Error {
|
|
constructor(
|
|
readonly base: string,
|
|
readonly donor: string,
|
|
readonly half: Half,
|
|
) {
|
|
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
|
|
this.name = "AssembleError";
|
|
}
|
|
}
|
|
|
|
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
|
|
const canonical = (value: unknown): string =>
|
|
JSON.stringify(value, (_key, v) =>
|
|
v && typeof v === "object" && !Array.isArray(v)
|
|
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
: v,
|
|
);
|
|
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
|
|
|
|
/**
|
|
* The base card with `half` of its text replaced by the same half of `donor`.
|
|
* It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
|
*/
|
|
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
|
|
const topOf = (c: CardDef) => c.text.slice(0, c.split);
|
|
const bottomOf = (c: CardDef) => c.text.slice(c.split);
|
|
const top = half === "top" ? topOf(donor) : topOf(base);
|
|
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
|
|
const text = [...top, ...bottom];
|
|
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
|
|
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
|
|
}
|
|
|
|
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
|
|
try {
|
|
assemble(base, donor, half);
|
|
return true;
|
|
} catch (e) {
|
|
if (e instanceof AssembleError) return false;
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Register an assembled card and return its name. If a different card already has
|
|
* the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
|
*/
|
|
export function registerAssembled(world: World, def: CardDef): string {
|
|
const stem = def.name.replace(/\)$/, "");
|
|
let name = def.name;
|
|
// Same base, so the same name and stats: only the text can differ.
|
|
for (let n = 2; world.hasCard(name) && !sameText(world.card(name).def.text, def.text); n++) {
|
|
name = `${stem} ${n})`;
|
|
}
|
|
world.registerAssembled({ ...def, name });
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* The `assemble` effect: the owner's chooser picks a base minion from the pool
|
|
* (filtered), a donor that changes its text, and the half to replace. The
|
|
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
|
|
* random and offers two donors; here every choice is the chooser's.
|
|
*/
|
|
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
|
|
const pool = world.pool();
|
|
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
|
|
if (bases.length === 0) return undefined;
|
|
const base = world.card(world.choose("base", bases)).def;
|
|
|
|
const donors = pool
|
|
.map((c) => c.def)
|
|
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
|
|
.map((d) => d.name);
|
|
if (donors.length === 0) return undefined;
|
|
const donor = world.card(world.choose("donor", donors)).def;
|
|
|
|
const half = world.choose(
|
|
"half",
|
|
HALVES.filter((h) => changes(base, donor, h)),
|
|
) as Half;
|
|
const name = registerAssembled(world, assemble(base, donor, half));
|
|
return world.create(name, player, "hand");
|
|
}
|