Compare commits

..

19 Commits

Author SHA1 Message Date
hypercross fb66ec55c4 feat: cost implementation for card play 2026-04-17 14:10:28 +08:00
hypercross 8155747cac chore: tests for effects and middleware 2026-04-17 13:48:18 +08:00
hypercross 7601a97ec9 chore: non-combat tests update 2026-04-17 12:58:29 +08:00
hypercross f775d51a58 feat: effect and triggers 2026-04-17 12:58:12 +08:00
hypercross aa36f3ea67 feat: enemy intent update 2026-04-17 12:42:29 +08:00
hypercross 0f04af2c6e refactor: update progress 2026-04-17 12:23:10 +08:00
hypercross c11bceeb44 refactor: update generator 2026-04-17 12:10:10 +08:00
hypercross 1d749f59a6 feat: add posture damage & item effect update trigger 2026-04-17 11:57:40 +08:00
hypercross 65afe6dc8f refactor: remove card effect cycle, just use instant instead 2026-04-17 11:13:28 +08:00
hypercross 3a135a4ad1 refactor: reorg 2026-04-17 11:06:09 +08:00
hypercross 4deebf67c3 chore: remove old tests 2026-04-17 10:20:04 +08:00
hypercross 1c238aec3a refactor: type rewrite 2026-04-17 10:18:37 +08:00
hypercross a469b4024a refactor: combat rewrite 2026-04-17 09:27:20 +08:00
hypercross 7d8684a16f refactor: middle ware triggers 2026-04-17 08:33:02 +08:00
hypercross 3dc566c2fd refactor: types 2026-04-17 01:28:43 +08:00
hypercross 3f3490fad8 refactor: reorg 2026-04-17 01:05:48 +08:00
hypercross 5e55b58c43 refactor: data reorg complete 2026-04-17 01:01:26 +08:00
hypercross f8c008b67d wip: data reorg 2026-04-17 00:52:47 +08:00
hypercross c0fa0e91b2 refactor: avoid using types from the csv 2026-04-17 00:28:25 +08:00
75 changed files with 2000 additions and 3994 deletions

View File

@ -1,5 +1,5 @@
import {Part} from "./part";
import {RNG} from "@/utils/rng";
import {ReadonlyRNG} from "@/utils/rng";
export type Region = {
id: string;
@ -107,7 +107,7 @@ export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMe
region.partMap = buildPartMap(region, parts);
}
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: ReadonlyRNG){
if (region.childIds.length <= 1) return;
const childIds = [...region.childIds];

View File

@ -1,102 +0,0 @@
# slay-the-spire-like
A Slay the Spire + Backpack Heroes hybrid roguelike sample. Players explore a point-crawl map, manage a tetris-style grid inventory, and fight enemies using cards generated from their equipment.
## Game Design Docs
Design docs are in the markdown files at this level:
- `01-overview.md` — core game concept, zones, encounter structure, combat rules, buff/debuff system
- `02-fighter.md` — Fighter class items (weapons, armor, tools, consumables, relics)
- `03-desert.md` — Desert zone enemies (minions, elites, boss)
- `data/rules.md` — combat state machine, turn order, effect timing rules
## Module Structure
This is **not** a `GameModule` yet — there is no `createInitialState`/`start`/`registry` wired up to `createGameHost`. The code is a library of subsystems that can be composed into a game module.
### Subsystems
| Directory | Purpose | Key exports |
|-----------|---------|-------------|
| `progress/` | Run state, player HP/gold, inventory management, map progression | `createRunState`, `moveToNode`, `resolveEncounter`, `damagePlayer`, `healPlayer`, `addItemFromCsv`, `removeItem`, `getReachableChildren` |
| `map/` | Point-crawl map generation and traversal | `generatePointCrawlMap`, `getNode`, `getChildren`, `getParents`, `hasPath`, `findAllPaths` |
| `grid-inventory/` | Tetris-style grid placement (place, move, rotate, flip items) | `createGridInventory`, `placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`, `validatePlacement`, `getAdjacentItems` |
| `deck/` | Card/deck system (draw pile, hand, discard, exhaust) | `generateDeckFromInventory`, `createStatusCard`, `createDeckRegions`, `createPlayerDeck` |
| `data/` | CSV game data loaded via `inline-schema/csv-loader`. `.d.ts` files are auto-generated by the csv-loader plugin — do not edit by hand. | `heroItemFighter1Data`, `encounterDesertData`, `enemyDesertData`, `enemyIntentDesertData`, `effectDesertData`, `statusCardDesertData` |
| `dialogue/` | Yarn Spinner dialogue files (placeholder). Loaded via `yarn-spinner-loader`, a local peer dependency at `../yarn-spinner-loader` (like `inline-schema`, it can be changed and published if needed). | `encounters` yarnproject |
| `utils/` | Shape parsing and collision math | `parseShapeString`, `checkCollision`, `checkBounds`, `transformShape`, `rotateTransform`, `flipXTransform`, `flipYTransform` |
### Data flow
```
CSV files (data/)
→ inline-schema/csv-loader → typed JS objects (e.g. HeroItemFighter1)
→ parseShapeString() converts shape strings → ParsedShape
→ GridInventory<GameItemMeta> holds placed items
→ generateDeckFromInventory() generates cards per occupied cell
```
### Key types
- **`RunState`** — top-level state: seed, map, player, inventory, currentNodeId, encounter state, resolved set. Designed for `MutableSignal.produce()` mutation.
- **`GridInventory<TMeta>`** — `items: Map<string, InventoryItem<TMeta>>` + `occupiedCells: Set<CellKey>` for O(1) collision. Mutated directly inside `.produce()`.
- **`InventoryItem<TMeta>`** — id, shape (ParsedShape), transform (Transform2D), meta. Shape + transform determines which cells are occupied.
- **`GameCard`** — a `Part<GameCardMeta>` bridging inventory items to the deck system. `sourceItemId` links back to the inventory item; `null` for status cards.
- **`PointCrawlMap`** — layered DAG: 10 layers (start → wild×2 → settlement → wild×2 → settlement → wild×2 → end). Wild = 3 nodes, Settlement = 4 nodes.
- **`MapNode`** — id, type (MapNodeType enum), childIds, optional encounter data from CSV.
### Map generation
`generatePointCrawlMap(seed?)` produces a deterministic map:
- 10 layers: Start → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → End
- Settlement layers guarantee ≥1 camp, ≥1 shop, ≥1 curio (4th slot random)
- Wild pair types are optimized to minimize same-type repetition
- Edge patterns avoid crossings: Start→all wild, Wild→Wild 1:1, Wild↔Settlement 3:4 or 4:3, Wild→all End
### Shape system
Items have shapes defined as movement strings parsed by `parseShapeString`:
- `o` = origin cell, `n/s/e/w` = move + fill, `r` = return to previous position
- Example: `"oesw"` = 2×2 block (origin, east, south, west = full square)
- Example: `"oe"` = 1×2 horizontal
- Example: `"onrersrw"` = cross/X shape
Shapes are positioned via `Transform2D` (offset, rotation, flipX, flipY) and validated against the 6×4 grid.
### Grid inventory
All mutation functions (`placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`) mutate the `GridInventory` **directly** — they must be called inside `produce()` callbacks. `validatePlacement` checks bounds + collisions before placement.
### Card generation
`generateDeckFromInventory(inventory)` creates one card per occupied cell in each item's shape. Cards carry `GameCardMeta` linking back to the source item and cell position. Status cards (wound, venom, etc.) are created separately via `createStatusCard`.
## CSV data format
All CSVs use `inline-schema` typed headers. The first row is a comment header, the second row is the schema row with types and references:
- `'energy'|'uses'` — union type
- `@enemyDesert` — foreign key reference to another CSV
- `[effect: @effectDesert; number][]` — array of structured references
### heroItemFighter1.csv columns
| Column | Type | Notes |
|--------|------|-------|
| type | `'weapon'|'armor'|'consumable'|'tool'` | |
| name | string | Display name (Chinese) |
| shape | string | Movement string for `parseShapeString` |
| costType | `'energy'|'uses'` | Energy = per-turn cost; Uses = limited uses |
| costCount | int | Cost amount |
| targetType | `'single'|'none'` | |
| price | int | Shop price |
| desc | string | Ability description (Chinese) |
| effects | `['self'|'target'|'all'|'random'; @effectDesert; number][]` | Effect references |
## Conventions
- Chinese is used for all user-facing strings (item names, error messages, effect descriptions)
- Discriminated union result types: `{ success: true } | { success: false, reason: string }`
- Mutation functions mutate state directly (inside `produce()`); validation is separate
- `Map` and `Set` are used in `GridInventory` and `PointCrawlMap` (not plain objects) — requires careful handling with `mutative` since it drafts Maps/Sets differently than plain objects
- Starter items defined in `progress/index.ts`: `['治疗药剂', '绷带', '水袋', '短刀', '剑']`
- Default player stats: 50 HP, 50 gold, 6×4 inventory

View File

@ -1,2 +0,0 @@
# 《背包爬塔》肉鸽

View File

@ -1,538 +0,0 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import type { CardDesert } from "../data/cardDesert.csv";
import { effectDesertData, cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { PlayerDeck, GameCard } from "../deck/types";
import type {
BuffTable,
CombatEffectEntry,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
PlayerCombatState,
} from "./types";
import {
drawCardsToHand,
addFatigueCards,
discardCard,
exhaustCard,
getEffectData,
discardHand,
} from "./state";
export type DamageResult = {
damageDealt: number;
blockedByDefend: number;
targetDied: boolean;
};
export function applyDamage(
state: CombatState,
targetKey: "player" | string,
amount: number,
sourceKey?: "player" | string,
): DamageResult {
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
let actualDamage = amount;
let blockedByDefend = 0;
if (targetKey === "player") {
const defendStacks = state.player.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
state.player.buffs["defend"] = defendStacks - blockedByDefend;
if (state.player.buffs["defend"] === 0) {
delete state.player.buffs["defend"];
}
}
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
if (damageReduce > 0 && actualDamage > 0) {
actualDamage = Math.max(0, actualDamage - damageReduce);
}
if (actualDamage > 0) {
state.player.hp = Math.max(0, state.player.hp - actualDamage);
state.player.damageTakenThisTurn += actualDamage;
state.player.damagedThisTurn = true;
}
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
// Not relevant for player, skip
}
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: state.player.hp <= 0,
};
}
const enemy = state.enemies[targetKey];
if (!enemy || !enemy.isAlive) {
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
}
const defendStacks = enemy.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
enemy.buffs["defend"] = defendStacks - blockedByDefend;
if (enemy.buffs["defend"] === 0) {
delete enemy.buffs["defend"];
}
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
enemy.hadDefendBroken = true;
}
}
if (actualDamage > 0) {
enemy.hp = Math.max(0, enemy.hp - actualDamage);
if (enemy.hp <= 0) {
enemy.isAlive = false;
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: !enemy.isAlive,
};
}
export function applyDefend(
targetBuffs: BuffTable,
amount: number,
): void {
if (amount <= 0) return;
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
}
export function applyBuff(
buffs: BuffTable,
effectId: string,
timing: EffectTiming,
stacks: number,
): void {
if (stacks <= 0) return;
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
}
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
const current = buffs[effectId] ?? 0;
if (stacks === undefined || stacks >= current) {
delete buffs[effectId];
return current;
}
buffs[effectId] = current - stacks;
return stacks;
}
export function updateBuffs(buffs: BuffTable): void {
const toDelete: string[] = [];
const toDecrement: string[] = [];
for (const [effectId] of Object.entries(buffs)) {
const effectData = getEffectData(effectId);
if (!effectData) continue;
switch (effectData.timing) {
case "temporary":
toDelete.push(effectId);
break;
case "lingering":
toDecrement.push(effectId);
break;
}
}
for (const id of toDelete) {
delete buffs[id];
}
for (const id of toDecrement) {
buffs[id] = (buffs[id] ?? 0) - 1;
if (buffs[id] <= 0) {
delete buffs[id];
}
}
}
export type ResolveEffectContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export function resolveEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state } = ctx;
const timing = effect.timing;
switch (timing) {
case "instant":
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
break;
case "posture":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "temporary":
case "lingering":
case "permanent":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "card":
addStatusCardToDiscard(state, effect.id, stacks);
break;
case "cardDraw":
addStatusCardToDrawPile(state, effect.id, stacks);
break;
case "cardHand":
addStatusCardToHand(state, effect.id, stacks);
break;
case "item":
case "itemUntilPlayed":
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
break;
}
}
function applyBuffToTarget(
state: CombatState,
target: EffectTarget,
effectId: string,
stacks: number,
sourceKey?: "player" | string,
): void {
if (target === "self") {
if (sourceKey === "player") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (sourceKey && state.enemies[sourceKey]) {
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
}
} else if (target === "player" || target === "team") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (target === "target" || target === "all" || target === "random") {
// For attack/defend effects, these are handled by resolveInstantEffect
}
}
function resolveInstantEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state, rng } = ctx;
switch (effect.id) {
case "attack": {
const damageAmount = stacks;
if (target === "all") {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive) {
applyDamage(state, enemyId, damageAmount, sourceKey);
}
}
} else if (target === "random") {
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
if (aliveEnemies.length > 0) {
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
applyDamage(state, targetId, damageAmount, sourceKey);
}
} else if (target === "target") {
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
applyDamage(state, "player", damageAmount, sourceKey);
}
}
break;
}
case "defend": {
if (target === "self" && sourceKey === "player") {
applyDefend(state.player.buffs, stacks);
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
applyDefend(state.enemies[sourceKey].buffs, stacks);
}
break;
}
case "draw": {
drawCardsToHand(state.player.deck, stacks);
break;
}
case "gainEnergy": {
state.player.energy += stacks;
break;
}
case "removeWound": {
removeWoundCards(state.player.deck, stacks);
break;
}
case "tailSting": {
applyDamage(state, "player", stacks, sourceKey);
break;
}
case "rollDamage": {
const rollStacks = sourceKey && sourceKey !== "player"
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
: 0;
if (rollStacks >= 10) {
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
}
break;
}
case "crossbow": {
break;
}
case "discard": {
break;
}
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy": {
break;
}
case "drawChoice":
case "transformRandom": {
break;
}
default:
break;
}
}
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.discardPile.push(card.id);
}
}
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.drawPile.push(card.id);
}
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
function applyItemBuff(
state: CombatState,
effectId: string,
timing: EffectTiming,
stacks: number,
sourceCardId?: string,
): void {
if (!sourceCardId) return;
const card = state.player.deck.cards[sourceCardId];
if (!card || !card.sourceItemId) return;
const itemBuff: ItemBuff = {
effectId,
stacks,
timing,
sourceItemId: card.sourceItemId,
targetItemId: card.sourceItemId,
};
state.player.itemBuffs.push(itemBuff);
}
function removeWoundCards(deck: PlayerDeck, count: number): void {
let removed = 0;
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.drawPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.drawPile[i]];
deck.drawPile.splice(i, 1);
removed++;
}
}
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.discardPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.discardPile[i]];
deck.discardPile.splice(i, 1);
removed++;
}
}
}
export function resolveCardEffects(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): void {
const { state } = ctx;
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return;
const sourceKey: "player" | string = "player";
const effects = card.itemData.onPlay as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (target === "target") {
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
if (effect.id === "attack") {
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
applyDamage(state, targetEnemyId, actualDamage, "player");
continue;
}
}
}
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
}
}
export function getModifiedAttackDamage(
state: CombatState,
cardId: string,
baseDamage: number,
): number {
let damage = baseDamage;
const attackBuff = state.player.itemBuffs
.filter((b) => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
damage += attackBuff;
return Math.max(0, damage);
}
export function getModifiedDefendAmount(
state: CombatState,
cardId: string,
baseDefend: number,
): number {
let defend = baseDefend;
const defendBuff = state.player.itemBuffs
.filter((b) => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
defend += defendBuff;
return Math.max(0, defend);
}
export function canPlayCard(
state: CombatState,
cardId: string,
): { canPlay: boolean; reason?: string } {
const card = state.player.deck.cards[cardId];
if (!card) return { canPlay: false, reason: "卡牌不存在" };
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
const handIdx = state.player.deck.hand.indexOf(cardId);
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
if (card.itemData.costType === "energy") {
if (state.player.energy < card.itemData.costCount) {
return { canPlay: false, reason: "能量不足" };
}
}
return { canPlay: true };
}
export function playCard(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): { success: boolean; reason?: string } {
const { state } = ctx;
const check = canPlayCard(state, cardId);
if (!check.canPlay) return { success: false, reason: check.reason };
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
if (card.itemData.costType === "energy") {
state.player.energy -= card.itemData.costCount;
}
resolveCardEffects(ctx, cardId, targetEnemyId);
if (card.itemData.costType === "uses") {
exhaustCard(state.player.deck, cardId);
} else {
discardCard(state.player.deck, cardId);
}
expireItemBuffsOnCardPlayed(state, cardId);
return { success: true };
}
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
const card = state.player.deck.cards[cardId];
if (!card || !card.sourceItemId) return;
state.player.itemBuffs = state.player.itemBuffs.filter((buff) => {
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
return false;
}
return true;
});
}
export function areAllEnemiesDead(state: CombatState): boolean {
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
}
export function isPlayerDead(state: CombatState): boolean {
return state.player.hp <= 0;
}

View File

@ -1,71 +0,0 @@
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from "./types";
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
reshuffleDiscardIntoDraw,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
export {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
} from "./effects";
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from "./triggers";
export {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
} from "./triggers";
export { prompts } from "./prompts";
export { runCombat } from "./procedure";

View File

@ -1,309 +0,0 @@
import type { IGameContext } from "@/core/game";
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers";
import { prompts } from "./prompts";
import {
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
import {
applyDamage,
applyDefend,
updateBuffs,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
resolveCardEffects,
removeBuff,
} from "./effects";
export async function runCombat(
game: CombatGameContext,
): Promise<CombatResult> {
const triggerRegistry = createCombatTriggerRegistry();
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.player.energy = state.player.maxEnergy;
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
});
while (true) {
const currentState = game.value;
if (currentState.result) {
return currentState.result;
}
if (currentState.phase === "playerTurn") {
await runPlayerTurn(game, triggerRegistry);
} else if (currentState.phase === "enemyTurn") {
await runEnemyTurn(game, triggerRegistry);
} else {
break;
}
if (isPlayerDead(game.value)) {
await game.produceAsync((state) => {
state.result = "defeat";
state.phase = "combatEnd";
});
return "defeat";
}
if (areAllEnemiesDead(game.value)) {
await game.produceAsync((state) => {
state.result = "victory";
state.phase = "combatEnd";
state.loot = generateLoot(state);
});
return "victory";
}
}
return game.value.result ?? "defeat";
}
async function runPlayerTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const triggerCtx = createTriggerContext(game);
await game.produceAsync(async (state) => {
updateBuffs(state.player.buffs);
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
});
while (game.value.phase === "playerTurn") {
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
prompts.playCard,
(cardId, targetId) => {
const state = game.value;
if (!cardId) throw "请选择卡牌";
const check = canPlayCard(state, cardId);
if (!check.canPlay) throw check.reason ?? "无法打出";
const card = state.player.deck.cards[cardId];
if (card?.itemData?.targetType === "single") {
const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive);
if (!targetId && aliveEnemies.length > 0) {
throw "请指定目标";
}
if (targetId && !state.enemies[targetId]?.isAlive) {
throw "目标无效";
}
}
return { action: "play" as const, cardId, targetId };
},
"player"
);
if (action.action === "play" && action.cardId) {
const ctx = createEffectContext(game);
await game.produceAsync(async (state) => {
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
});
if (areAllEnemiesDead(game.value)) {
return;
}
if (isPlayerDead(game.value)) {
return;
}
continue;
}
break;
}
await game.prompt<{ action: "end" }>(
prompts.endTurn,
() => {
return { action: "end" as const };
},
"player"
);
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
await game.produceAsync(async (state) => {
for (const cardId of [...state.player.deck.hand]) {
state.player.cardsDiscardedThisTurn++;
}
discardHand(state.player.deck);
});
await game.produceAsync(async (state) => {
if (state.player.deck.drawPile.length === 0) {
reshuffleWithFatigue(state);
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
}
const drawn = drawCardsToHand(state.player.deck, 5);
for (const cardId of drawn) {
dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry);
}
state.player.energy = state.player.maxEnergy;
});
await game.produceAsync(async (state) => {
state.phase = "enemyTurn";
});
}
async function runEnemyTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const state = game.value;
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
updateBuffs(enemy.buffs);
}
});
const triggerCtx = createTriggerContext(game);
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
const intent = getEnemyCurrentIntent(enemy);
if (!intent) continue;
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (effect.id === "attack") {
let damage = stacks;
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
const result = applyDamage(state, "player", damage, enemyId);
if (result.damageDealt > 0) {
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
}
} else if (effect.id === "defend") {
if (target === "self") {
applyDefend(enemy.buffs, stacks);
}
} else {
resolveEnemyEffect(state, enemyId, target, effect, stacks);
}
}
advanceEnemyIntent(enemy);
}
});
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.turnNumber++;
});
}
function resolveEnemyEffect(
state: CombatState,
enemyId: string,
target: string,
effect: { id: string; timing: string },
stacks: number,
): void {
switch (effect.id) {
case "spike":
case "venom":
case "curse":
case "aim":
case "roll":
case "vultureEye":
case "tailSting":
case "energyDrain":
case "molt":
case "storm":
case "static":
case "charge":
case "discard":
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
break;
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy":
break;
default:
break;
}
}
function reshuffleWithFatigue(state: CombatState): void {
if (state.player.deck.discardPile.length === 0) return;
state.player.deck.drawPile.push(...state.player.deck.discardPile);
state.player.deck.discardPile = [];
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.player.fatigueAddedCount });
state.player.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
}
}
function createTriggerContext(game: CombatGameContext): TriggerContext {
return {
state: game.value,
rng: game._rng,
};
}
function createEffectContext(game: CombatGameContext) {
return {
state: game.value,
rng: game._rng,
};
}
function generateLoot(state: CombatState): CombatState["loot"] {
const loot: CombatState["loot"] = [];
let totalGold = 0;
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
totalGold += Math.floor(enemy.maxHp * 0.5);
}
if (totalGold > 0) {
loot.push({ type: "gold", amount: totalGold });
}
return loot;
}

View File

@ -1,12 +0,0 @@
import { createPromptDef } from "@/core/game";
export const prompts = {
playCard: createPromptDef<[string, string?]>(
"play-card <cardId:string> [targetId:string]",
"选择卡牌并指定目标"
),
endTurn: createPromptDef<[]>(
"end-turn",
"结束回合"
),
};

View File

@ -1,254 +0,0 @@
import type { GridInventory } from "../grid-inventory/types";
import type { GameItemMeta, PlayerState } from "../progress/types";
import type { PlayerDeck } from "../deck/types";
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import type { EncounterDesert } from "../data/encounterDesert.csv";
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
import { enemyDesertData, effectDesertData, cardDesertData } from "../data";
import { createRNG } from "@/utils/rng";
import type {
BuffTable,
CombatState,
CombatPhase,
EnemyState,
PlayerCombatState,
ItemBuff,
LootEntry,
} from "./types";
const INITIAL_HAND_SIZE = 5;
const DEFAULT_MAX_ENERGY = 3;
const FATIGUE_CARDS_PER_SHUFFLE = 2;
export function createEnemyInstance(
templateId: string,
hp: number,
initBuffs: [EffectDesert, number][],
idCounter: { value: number },
): EnemyState {
idCounter.value++;
const id = `enemy-${idCounter.value}`;
const maxHp = hp;
const currentHp = hp;
const buffs: BuffTable = {};
for (const [effect, stacks] of initBuffs) {
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
}
const intentData = buildIntentLookup(templateId);
const initialIntent = findInitialIntent(templateId);
return {
id,
templateId,
hp: currentHp,
maxHp,
buffs,
currentIntentId: initialIntent ?? "",
intentData,
isAlive: true,
hadDefendBroken: false,
};
}
function findInitialIntent(enemyTemplateId: string): string | undefined {
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId && row.initialIntent) {
return row.intentId;
}
}
return undefined;
}
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyDesert> {
const lookup: Record<string, EnemyDesert> = {};
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId) {
lookup[row.intentId] = row;
}
}
return lookup;
}
export function createPlayerCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
): PlayerCombatState {
const deck = generateDeckFromInventory(inventory);
return {
hp: playerState.currentHp,
maxHp: playerState.maxHp,
energy: DEFAULT_MAX_ENERGY,
maxEnergy: DEFAULT_MAX_ENERGY,
buffs: {},
deck,
damageTakenThisTurn: 0,
damagedThisTurn: false,
cardsDiscardedThisTurn: 0,
itemBuffs: [],
fatigueAddedCount: 0,
};
}
export function createCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
encounter: EncounterDesert,
): CombatState {
const idCounter = { value: 0 };
const player = createPlayerCombatState(playerState, inventory);
const enemies: Record<string, EnemyState> = {};
const enemyOrder: string[] = [];
const enemyTemplateData: Record<string, EnemyDesert> = {};
for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) {
const [enemyId, hp, bonusHp] = enemyEntry;
// Find initBuffs from enemyDesert (first row for this enemy type)
const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId);
const initBuffs: [EffectDesert, number][] = [];
if (enemyRow) {
for (const [effect, stacks] of enemyRow.initBuffs) {
initBuffs.push([effect, stacks]);
}
}
const totalHp = hp + bonusHp;
const enemyInstance = createEnemyInstance(
enemyId,
totalHp,
initBuffs,
idCounter,
);
enemies[enemyInstance.id] = enemyInstance;
enemyOrder.push(enemyInstance.id);
enemyTemplateData[enemyInstance.templateId] = enemyRow!;
}
shuffleDeck(player.deck.drawPile, createRNG(0));
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
return {
enemies,
enemyOrder,
player,
phase: "playerTurn" as CombatPhase,
turnNumber: 1,
result: null,
loot: [],
enemyTemplateData,
};
}
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
const drawn: string[] = [];
for (let i = 0; i < count; i++) {
if (deck.drawPile.length === 0) {
reshuffleDiscardIntoDraw(deck);
}
if (deck.drawPile.length === 0) break;
const cardId = deck.drawPile.shift()!;
deck.hand.push(cardId);
drawn.push(cardId);
}
return drawn;
}
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
if (deck.discardPile.length === 0) return;
deck.drawPile.push(...deck.discardPile);
deck.discardPile = [];
for (let i = deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
}
}
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
let added = 0;
const fatigueDef = cardDesertData.find((c) => c.id === "fatigue");
if (!fatigueDef) return 0;
for (let i = 0; i < count; i++) {
fatigueCounter.value++;
const card = createStatusCard(
`fatigue-${fatigueCounter.value}`,
fatigueDef.name,
fatigueDef.desc,
);
deck.cards[card.id] = card;
deck.drawPile.push(card.id);
added++;
}
return added;
}
export function discardHand(deck: PlayerDeck): void {
const handCards = [...deck.hand];
deck.discardPile.push(...handCards);
deck.hand = [];
}
export function discardCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.discardPile.push(cardId);
}
}
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.exhaustPile.push(cardId);
}
}
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyDesert | undefined {
return enemy.intentData[enemy.currentIntentId];
}
export function advanceEnemyIntent(enemy: EnemyState): void {
const current = getEnemyCurrentIntent(enemy);
if (!current) return;
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
const idx = Math.floor(Math.random() * current.brokenIntent.length);
enemy.currentIntentId = current.brokenIntent[idx];
enemy.hadDefendBroken = false;
return;
}
if (current.nextIntents.length > 0) {
const idx = Math.floor(Math.random() * current.nextIntents.length);
enemy.currentIntentId = current.nextIntents[idx];
return;
}
enemy.currentIntentId = current.intentId;
}
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
for (let i = drawPile.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
}
}
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
const effect = effectDesertData.find(e => e.id === effectId);
return effect?.timing;
}
export function getEffectData(effectId: string): EffectDesert | undefined {
return effectDesertData.find(e => e.id === effectId);
}
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };

View File

@ -1,347 +0,0 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import { cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
import { applyDamage, removeBuff } from "./effects";
export type TriggerContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export type BuffTriggerBehavior = {
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
};
export type TriggerEvent =
| "onTurnStart"
| "onTurnEnd"
| "onAttacked"
| "onDamage"
| "modifyOutgoingDamage"
| "modifyIncomingDamage"
| "onShuffle"
| "onCardPlayed"
| "onCardDiscarded"
| "onCardDrawn";
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
return {
spike: {
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
const { state } = ctx;
applyDamage(state, attackerKey, stacks, _defenderKey);
return damage;
},
},
aim: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) return damage * 2;
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player" ? null : state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "aim", loss);
}
},
},
charge: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "charge", loss);
}
},
},
roll: {
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
if (stacks >= 10) {
const { state } = ctx;
const entity = sourceKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[sourceKey];
if (entity) {
const spendable = Math.floor(stacks / 10) * 10;
const bonusDamage = Math.floor(spendable / 10);
removeBuff(entity.buffs, "roll", spendable);
return damage + bonusDamage;
}
}
return damage;
},
},
tailSting: {
onTurnEnd(ctx, entityKey, stacks) {
const { state } = ctx;
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
applyDamage(state, "player", stacks, entityKey);
}
},
},
energyDrain: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey === "player" && state.player.damagedThisTurn === false) {
// This is the first damage; mark it.
// actual energy drain happens in onTurnStart check
}
},
onTurnStart(ctx, entityKey, _stacks) {
// energyDrain: first damage each turn loses 1 energy
// We just mark that the enemy has this; actual drain is in onDamage
},
},
molt: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey !== "player") {
const enemy = state.enemies[targetKey];
if (enemy && enemy.isAlive) {
const moltStacks = enemy.buffs["molt"] ?? 0;
if (moltStacks >= enemy.maxHp) {
enemy.isAlive = false;
}
}
}
},
},
storm: {
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
const { state } = ctx;
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
addStatusCardToHand(state, "static", 1);
}
return damage;
},
},
vultureEye: {
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
if (targetKey === "player" && damage > 0) {
const vultureEnemies = state.enemyOrder.filter(
(id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
);
if (vultureEnemies.length > 0) {
for (const vultureId of vultureEnemies) {
const vulture = state.enemies[vultureId];
const intent = vulture.intentData["attack"];
if (intent) {
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
if (entry[0] === "player" && entry[1].id === "attack") {
applyDamage(state, "player", entry[2], vultureId);
}
}
}
}
}
}
},
},
venom: {
onCardDiscarded(ctx, cardId, stacks) {
const { state } = ctx;
state.player.cardsDiscardedThisTurn++;
const venomCards = state.player.deck.hand.filter((id) => {
const card = state.player.deck.cards[id];
return card && card.itemData === null && card.displayName === "蛇毒";
});
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
applyDamage(state, "player", 6, undefined);
}
},
},
static: {
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
if (targetKey === "player") {
return damage + stacks;
}
return damage;
},
},
discard: {
onShuffle(ctx, stacks) {
// Bandit: shuffle discards random item cards
// Simplified: mark the effect for the procedure to handle
},
},
curse: {
onDamage(ctx, targetKey, damage, stacks) {
// Curse: when attacked, item attack -1 until card from that item is discarded
// This is handled via itemBuffs in effects
},
},
};
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find((c) => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
export function dispatchTrigger(
ctx: TriggerContext,
event: TriggerEvent,
entityKey: "player" | string,
registry: CombatTriggerRegistry,
): void {
const buffs = entityKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[entityKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior) continue;
const handler = behavior[event];
if (handler) {
handler(ctx, entityKey, stacks);
}
}
}
export function dispatchAttackedTrigger(
ctx: TriggerContext,
attackerKey: "player" | string,
defenderKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = defenderKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[defenderKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onAttacked) continue;
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): void {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onDamage) continue;
behavior.onDamage(ctx, targetKey, damage, stacks);
}
}
export function dispatchOutgoingDamageTrigger(
ctx: TriggerContext,
sourceKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = sourceKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[sourceKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyOutgoingDamage) continue;
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchIncomingDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyIncomingDamage) continue;
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchShuffleTrigger(
ctx: TriggerContext,
registry: CombatTriggerRegistry,
): void {
for (const enemyId of ctx.state.enemyOrder) {
const enemy = ctx.state.enemies[enemyId];
if (!enemy.isAlive) continue;
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
const behavior = registry[buffId];
if (!behavior?.onShuffle) continue;
behavior.onShuffle(ctx, stacks);
}
}
}
export function dispatchCardDrawnTrigger(
ctx: TriggerContext,
cardId: string,
registry: CombatTriggerRegistry,
): void {
const buffs = ctx.state.player.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onCardDrawn) continue;
behavior.onCardDrawn(ctx, cardId, stacks);
}
}

View File

@ -1,77 +0,0 @@
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import type { PlayerDeck, GameCard } from "../deck/types";
import type { PlayerState } from "../progress/types";
export type BuffTable = Record<string, number>;
/** Lifecycle timing for effects - matches CSV timing column */
export type EffectTiming = EffectDesert["timing"];
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
export type ItemBuff = {
effectId: string;
stacks: number;
timing: EffectTiming;
sourceItemId: string;
targetItemId: string;
};
export type EnemyState = {
id: string;
templateId: string;
hp: number;
maxHp: number;
buffs: BuffTable;
currentIntentId: string;
intentData: Record<string, EnemyDesert>;
isAlive: boolean;
hadDefendBroken: boolean;
};
export type PlayerCombatState = {
hp: number;
maxHp: number;
energy: number;
maxEnergy: number;
buffs: BuffTable;
deck: PlayerDeck;
damageTakenThisTurn: number;
damagedThisTurn: boolean;
cardsDiscardedThisTurn: number;
itemBuffs: ItemBuff[];
fatigueAddedCount: number;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
type: "gold" | "item" | "relic";
amount?: number;
itemId?: string;
};
export type CombatState = {
enemies: Record<string, EnemyState>;
enemyOrder: string[];
player: PlayerCombatState;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
enemyTemplateData: Record<string, EnemyDesert>;
};
export type CombatEffectEntry = [EffectTarget, EffectDesert, number];
export type CombatEntity = {
buffs: BuffTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;

View File

@ -1,41 +0,0 @@
# cardDesert: unified card definitions for item cards and status cards
# type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target
# unplayable: true for status cards that cannot be played
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
id,name,desc,type,costType,costCount,targetType,unplayable,onPlay,onDraw,onDiscard
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',boolean,['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][]
sword,剑,【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2],,
greataxe,长斧,对全体【攻击5】,item,energy,2,none,false,[all;attack;5],,
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2];[target;attack;2],,
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,false,[target;attack;3];[target;attack;3],,
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,false,[target;attack;1];[self;draw;1],,
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,false,[target;attack;6];[self;crossbow;0],,
shield,盾,【防御3】,item,energy,1,none,false,[self;defend;3],,
hat,斗笠,【防御8】,item,energy,2,none,false,[self;defend;8],,
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,false,[self;defend;2];[self;defendNext;2],,
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,false,[self;defend;1];[self;draw;1],,
greatshield,大盾,【防御5】,item,energy,1,none,false,[self;defend;5],,
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,false,[self;damageReduce;3],,
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,false,[self;removeWound;1],,
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,false,[self;attackBuff;2],,
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,false,[self;defendBuff;2],,
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,false,[self;gainEnergy;1],,
focusPotion,集中药剂,抓2张牌,item,uses,3,none,false,[self;draw;2],,
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,false,[self;removeWound;3],,
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,false,[self;energyNext;1];[self;drawNext;2],,
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,false,[self;defendBuffUntilPlay;2],,
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,false,[self;drawChoice;1],,
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,false,[self;burnForEnergy;1],,
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,false,[self;attackBuffUntilPlay;3],,
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,false,[self;transformRandom;1],,
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,true,,,
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,true,,,[self;attack;3]
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,true,,[self;curse;1],
static,静电,在手里时受电击伤害+1,status,none,0,none,true,,[self;static;1],
fatigue,疲劳,占用手牌,status,none,0,none,true,,,
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,true,,[self;expose;3],
1 # cardDesert: unified card definitions for item cards and status cards
2 # type: 'item' = inventory item card, 'status' = status effect card
3 # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
4 # targetType: 'single' = target one enemy, 'none' = no target
5 # unplayable: true for status cards that cannot be played
6 # onPlay: effects triggered when card is played
7 # onDraw: effects triggered when card enters hand
8 # onDiscard: effects triggered when card is discarded
9 id,name,desc,type,costType,costCount,targetType,unplayable,onPlay,onDraw,onDiscard
10 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',boolean,['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][]
11 sword,剑,【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2],,
12 greataxe,长斧,对全体【攻击5】,item,energy,2,none,false,[all;attack;5],,
13 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2];[target;attack;2],,
14 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,false,[target;attack;3];[target;attack;3],,
15 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,false,[target;attack;1];[self;draw;1],,
16 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,false,[target;attack;6];[self;crossbow;0],,
17 shield,盾,【防御3】,item,energy,1,none,false,[self;defend;3],,
18 hat,斗笠,【防御8】,item,energy,2,none,false,[self;defend;8],,
19 cape,披风,【防御2】下回合【防御2】,item,energy,1,none,false,[self;defend;2];[self;defendNext;2],,
20 bracer,护腕,【防御1】抓1张牌,item,energy,0,none,false,[self;defend;1];[self;draw;1],,
21 greatshield,大盾,【防御5】,item,energy,1,none,false,[self;defend;5],,
22 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,false,[self;damageReduce;3],,
23 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,false,[self;removeWound;1],,
24 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,false,[self;attackBuff;2],,
25 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,false,[self;defendBuff;2],,
26 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,false,[self;gainEnergy;1],,
27 focusPotion,集中药剂,抓2张牌,item,uses,3,none,false,[self;draw;2],,
28 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,false,[self;removeWound;3],,
29 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,false,[self;energyNext;1];[self;drawNext;2],,
30 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,false,[self;defendBuffUntilPlay;2],,
31 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,false,[self;drawChoice;1],,
32 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,false,[self;burnForEnergy;1],,
33 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,false,[self;attackBuffUntilPlay;3],,
34 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,false,[self;transformRandom;1],,
35 wound,伤口,无效果占用手牌和牌堆,status,none,0,none,true,,,
36 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,true,,,[self;attack;3]
37 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,true,,[self;curse;1],
38 static,静电,在手里时受电击伤害+1,status,none,0,none,true,,[self;static;1],
39 fatigue,疲劳,占用手牌,status,none,0,none,true,,,
40 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,true,,[self;expose;3],

View File

@ -1,20 +0,0 @@
import type { EffectDesert } from './effectDesert.csv';
type CardDesertTable = readonly {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: "item" | "status";
readonly costType: "energy" | "uses" | "none";
readonly costCount: number;
readonly targetType: "single" | "none";
readonly unplayable: boolean;
readonly onPlay: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
readonly onDraw: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
readonly onDiscard: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
}[];
export type CardDesert = CardDesertTable[number];
declare function getData(): CardDesertTable;
export default getData;

View File

@ -0,0 +1,40 @@
# cardDesert: unified card definitions for item cards and status cards
# type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
id,name,desc,type,costType,costCount,targetType,effects
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][]
sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2]
greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5]
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2]
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3]
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1]
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0]
shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3]
hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8]
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2]
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1]
greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5]
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3]
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1]
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2]
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2]
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1]
focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2]
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3]
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2]
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2]
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1]
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1]
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3]
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1]
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3]
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1]
static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1]
fatigue,疲劳,占用手牌,status,none,0,none,
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3]
1 # cardDesert: unified card definitions for item cards and status cards
2 # type: 'item' = inventory item card, 'status' = status effect card
3 # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
4 # targetType: 'single' = target one enemy, 'none' = no target
5 # onPlay: effects triggered when card is played
6 # onDraw: effects triggered when card enters hand
7 # onDiscard: effects triggered when card is discarded
8 id,name,desc,type,costType,costCount,targetType,effects
9 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][]
10 sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2]
11 greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5]
12 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2]
13 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3]
14 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1]
15 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0]
16 shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3]
17 hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8]
18 cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2]
19 bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1]
20 greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5]
21 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3]
22 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1]
23 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2]
24 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2]
25 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1]
26 focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2]
27 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3]
28 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2]
29 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2]
30 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1]
31 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1]
32 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3]
33 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1]
34 wound,伤口,无效果占用手牌和牌堆,status,none,0,none,
35 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3]
36 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1]
37 static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1]
38 fatigue,疲劳,占用手牌,status,none,0,none,
39 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3]

View File

@ -0,0 +1,17 @@
import type { Effect } from './effect.csv';
type CardTable = readonly {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: "item" | "status";
readonly costType: "energy" | "uses" | "none";
readonly costCount: number;
readonly targetType: "single" | "none";
readonly effects: readonly ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number];
}[];
export type Card = CardTable[number];
declare function getData(): CardTable;
export default getData;

View File

@ -1,25 +1,27 @@
# instant: 不施加buff瞬间生效
# temporary: 施加buff下回合开始时失效
# lingering: 施加buff下回合开始时失去1层
# permanent: 施加buff
# posture: 施加buff每受到1点伤害移除1层
# card: 不施加buff对玩家时在玩家弃牌堆创建同名卡牌对敌人无效敌人没有牌堆
# cardDraw: 不施加buff在抓牌堆洗入同名卡牌
# cardHand不施加buff在玩家手牌中创建同名卡牌
# item: 施加buff到周围物品永久生效
# itemUntilPlayed: 施加buff到周围物品物品被打出后失效
id, name, description, timing
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'card'|'cardDraw'|'cardHand'|'item'|'itemUntilPlayed'
# item: 施加buff到周围物品永久生效
# itemTemporary: 施加buff到周围物品持续到下次buff更新
# itemUntilPlay: 施加buff到周围物品物品被打出后失效
# itemUntilDiscard: 施加buff到周围物品物品被弃掉后失效
# itemPermanent: 施加buff到周围物品持续整场冒险
id, name, description, lifecycle
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'item'|'itemTemporary'|'itemUntilPlay'|'itemUntilDiscard'|'itemPermanent'
attack, 攻击, 对对手造成伤害, instant
defend, 防御, 抵消下次行动前受到的伤害, posture
spike, 尖刺, 对攻击者造成X点伤害, permanent
venom, 蛇毒, 同名状态牌/1费打出时移除此牌。弃掉时受到3点伤害, cardHand
venom, 蛇毒, 同名状态牌/1费打出时移除此牌。弃掉时受到3点伤害, instant
curse, 诅咒, 受攻击时物品攻击-1直到弃掉一张该物品的牌, lingering
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
vultureEye, 秃鹫之眼, 抓到时获得3层暴露临时debuff受到的伤害+1/每层), cardDraw
vultureEye, 秃鹫之眼, 抓到时获得3层暴露临时debuff受到的伤害+1/每层), instant
tailSting, 尾刺, 攻击时伤害提升X, posture
energyDrain, 能量吸取, 受伤时玩家失去X点能量, lingering
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture
@ -35,14 +37,14 @@ crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant
defendNext, 下回合防御, 下回合开始时获得防御, temporary
damageReduce, 减伤, 本回合受到的伤害减少X, temporary
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlayed
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlayed
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay
gainEnergy, 获得能量, 获得X点能量, instant
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlayed
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlayed
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlayed
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
expose, 暴露, 受到的伤害+1/每层, temporary
Can't render this file because it has a wrong number of fields in line 12.

View File

@ -0,0 +1,11 @@
type EffectTable = readonly {
readonly id: string;
readonly name: string;
readonly description: string;
readonly lifecycle: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
}[];
export type Effect = EffectTable[number];
declare function getData(): EffectTable;
export default getData;

View File

@ -0,0 +1,37 @@
# minion (10): minor enemies
# elite (4): dangerous enemies
# event (1): random dangerous event that requires reaction
# shop (2): merchant who sells different stuff
# camp (2): consumable restock and heal
# curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; hp; buffs[]]
id,type,name,description,enemies,dialogue
string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[@enemy; int; [effect: @effect;stacks: int]][],string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】对攻击者造成X点伤害。,[仙人掌怪;20;[]];[仙人掌怪;20;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌1费打出时移除此牌。弃掉时受到3点伤害。,[蛇;14;[]];[蛇;14;[]],
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1直到弃掉一张该物品的牌。,[木乃伊;18;[]];[仙人掌怪;20;[]],
gunslinger,minion,枪手,概念单回高攻。【瞄准X】造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;[]],
tumbleweed_pair,minion,风卷草,概念:防+强化。【滚动X】攻击时每消耗10点【滚动】造成等量伤害。,[风卷草;22;[]];[风卷草;22;[]],
vulture_cactus,minion,秃鹫,概念:攻+防。若造成伤害玩家获得秃鹫之眼0费状态牌打出时移除。抓到时获得3层暴露。,[秃鹫;16;[]];[仙人掌怪;20;[]],
scorpion_snake,minion,沙蝎,概念:攻+强化。【尾刺X】姿态buff攻击时伤害提升X。,[沙蝎;14;[]];[蛇;14;[]],
sandworm_larva,minion,幼沙虫,概念:防+强化。每回合第一次受伤时玩家失去1点能量。,[幼沙虫;24;[]],
lizard_pair,minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;[]];[蜥蜴;20;[]],
bandit_gunslinger,minion,沙匪,概念弱化玩家。【劫掠】对玩家施加的延时debuff。回合开始时随机弃掉一张手牌。,[沙匪;16;[]];[枪手;16;[]],
storm_spirit,elite,风暴之灵,【风暴X】攻击时玩家获得1张静电。受伤时失去等量【风暴】。静电在手里时受【电击】伤害+1,[风暴之灵;44;[]],
mounted_gunslinger,elite,骑马枪手,【冲锋X】受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;[]];[枪手;20;[]],
sandworm_king,elite,沙虫王,召唤幼体沙虫每当玩家弃掉一张牌恢复1生命。,[沙虫王;55;[]],
desert_guard,elite,沙漠守卫,召唤木乃伊会复活木乃伊2次。,[沙漠守卫;48;[]];[木乃伊;20;[]],
desert_merchant,shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
nomad_caravan,shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
oasis_campfire,camp,绿洲篝火,篝火可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
cave_shelter,camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
desert_relic_in_sand,curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
desert_dry_well,curio,枯井,投入1能量可能获得药水或什么也没有。,,desert_dry_well
desert_ancient_stele,curio,古代石碑,阅读碑文获得随机Buff直到下次战斗结束。,,desert_ancient_stele
desert_storm_wreckage,curio,沙暴残骸,搜索残骸随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
desert_mirage_chest,curio,蜃景宝箱,打开宝箱50%获得宝藏50%为蜃景什么也没有。,,desert_mirage_chest
desert_buried_pot,curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
desert_weathered_statue,curio,风化雕像,献祭1生命获得一件随机遗物。,,desert_weathered_statue
desert_oasis_fragment,curio,绿洲碎片,小型绿洲恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
desert_mirage_event,event,海市蜃楼,随机遭遇可能获得宝藏或遭遇陷阱使用d6双阶段结构结算。,,desert_mirage_event
1 # minion (10): minor enemies
2 # elite (4): dangerous enemies
3 # event (1): random dangerous event that requires reaction
4 # shop (2): merchant who sells different stuff
5 # camp (2): consumable restock and heal
6 # curio (8): random pickup of treasure or resources
7 # enemies: array of [enemyId; hp; buffs[]]
8 id,type,name,description,enemies,dialogue
9 string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[@enemy; int; [effect: @effect;stacks: int]][],string
10 cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;[]];[仙人掌怪;20;[]],
11 snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;[]];[蛇;14;[]],
12 mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;[]];[仙人掌怪;20;[]],
13 gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;[]],
14 tumbleweed_pair,minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;[]];[风卷草;22;[]],
15 vulture_cactus,minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;[]];[仙人掌怪;20;[]],
16 scorpion_snake,minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;[]];[蛇;14;[]],
17 sandworm_larva,minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;[]],
18 lizard_pair,minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;[]];[蜥蜴;20;[]],
19 bandit_gunslinger,minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;[]];[枪手;16;[]],
20 storm_spirit,elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;[]],
21 mounted_gunslinger,elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;[]];[枪手;20;[]],
22 sandworm_king,elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;[]],
23 desert_guard,elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;[]];[木乃伊;20;[]],
24 desert_merchant,shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
25 nomad_caravan,shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
26 oasis_campfire,camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
27 cave_shelter,camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
28 desert_relic_in_sand,curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
29 desert_dry_well,curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
30 desert_ancient_stele,curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
31 desert_storm_wreckage,curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
32 desert_mirage_chest,curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
33 desert_buried_pot,curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
34 desert_weathered_statue,curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
35 desert_oasis_fragment,curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
36 desert_mirage_event,event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event

View File

@ -0,0 +1,16 @@
import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv';
type EncounterTable = readonly {
readonly id: string;
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
readonly enemies: readonly [Enemy, number, readonly [readonly effect: Effect, readonly stacks: number]];
readonly dialogue: string;
}[];
export type Encounter = EncounterTable[number];
declare function getData(): EncounterTable;
export default getData;

View File

@ -0,0 +1,16 @@
id,name,description
string,string,string
仙人掌怪,仙人掌怪,防+强化。【尖刺X】对攻击者造成X点伤害。
蛇,蛇,攻+强化。给玩家塞入蛇毒牌1费打出时移除此牌。弃掉时受到3点伤害
木乃伊,木乃伊,攻+防。【诅咒】:受攻击时物品【攻击】-1直到弃掉一张该物品的牌。
枪手,枪手,单回高攻。【瞄准X】造成双倍伤害。受伤时失去等量【瞄准】。
风卷草,风卷草,防+强化。【滚动X】攻击时每消耗10点【滚动】造成等量伤害。
秃鹫,秃鹫,攻+防。若造成伤害玩家获得秃鹫之眼0费状态牌打出时移除。抓到时获得3层暴露
沙蝎,沙蝎,攻+强化。【尾刺X】姿态buff攻击时伤害提升X。
幼沙虫,幼沙虫,防+强化。每回合第一次受伤时玩家失去1点能量。
蜥蜴,蜥蜴,攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
沙匪,沙匪,弱化玩家。【劫掠】对玩家施加的延时debuff。回合开始时随机弃掉一张手牌。
风暴之灵,风暴之灵,【风暴X】攻击时玩家获得1张静电。受伤时失去等量【风暴】。静电在手里时受【电击】伤害+1
骑马枪手,骑马枪手,【冲锋X】受到或造成的伤害翻倍并消耗等量的冲锋。
沙虫王,沙虫王,召唤幼体沙虫每当玩家弃掉一张牌恢复1生命。
沙漠守卫,沙漠守卫,召唤木乃伊会复活木乃伊2次。
1 id name description
2 string string string
3 仙人掌怪 仙人掌怪 防+强化。【尖刺X】:对攻击者造成X点伤害。
4 攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。
5 木乃伊 木乃伊 攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
6 枪手 枪手 单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
7 风卷草 风卷草 防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
8 秃鹫 秃鹫 攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。
9 沙蝎 沙蝎 攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。
10 幼沙虫 幼沙虫 防+强化。每回合第一次受伤时,玩家失去1点能量。
11 蜥蜴 蜥蜴 攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
12 沙匪 沙匪 弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
13 风暴之灵 风暴之灵 【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
14 骑马枪手 骑马枪手 【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
15 沙虫王 沙虫王 召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
16 沙漠守卫 沙漠守卫 召唤木乃伊;会复活木乃伊2次。

View File

@ -0,0 +1,10 @@
type EnemyTable = readonly {
readonly id: string;
readonly name: string;
readonly description: string;
}[];
export type Enemy = EnemyTable[number];
declare function getData(): EnemyTable;
export default getData;

View File

@ -0,0 +1,15 @@
import getCards from './card.csv';
import getEffects from './effect.csv';
import getEncounters from './encounter.csv';
import getEnemies from './enemy.csv';
import getIntents from './intent.csv';
import getItems from './item.csv';
export { default as dialogues } from './dialogues/dialogues.yarnproject';
export { addTriggers } from './triggers';
export const cards = getCards();
export const effects = getEffects();
export const encounters = getEncounters();
export const enemies = getEnemies();
export const intents = getIntents();
export const items = getItems();

View File

@ -8,7 +8,7 @@
# effects: effects executed when this intent is active
enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
string,string,boolean,string[],string[],[@effectDesert;stacks: int][],['self'|'player'|'team';@effectDesert;number][]
@enemy,string,boolean,string[],string[],[@effect;stacks: int][],['self'|'player'|'team';@effect;number][]
仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
1 # enemyDesert: enemy templates and their intent state machines
8 # effects: effects executed when this intent is active
9 enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
10 string,string,boolean,string[],string[],[@effectDesert;stacks: int][],['self'|'player'|'team';@effectDesert;number][] @enemy,string,boolean,string[],string[],[@effect;stacks: int][],['self'|'player'|'team';@effect;number][]
11 仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
12 仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
13 仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
14 蛇,poison,true,attack;attack,,,,[player;venom;1];[player;attack;4]

View File

@ -0,0 +1,17 @@
import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv';
type IntentTable = readonly {
readonly enemy: Enemy;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [Effect, readonly stacks: number];
readonly effects: readonly ["self" | "player" | "team", Effect, number];
}[];
export type Intent = IntentTable[number];
declare function getData(): IntentTable;
export default getData;

View File

@ -0,0 +1,26 @@
id,type,name,shape,card,price,description
string,string,string,string,@card,int,string
sword,weapon,剑,oee,sword,50,【攻击2】【攻击2】
greataxe,weapon,长斧,oees,greataxe,80,对全体【攻击5】
spear,weapon,长枪,oeee,spear,75,【攻击2】【攻击2】【攻击2】
dagger,weapon,短刀,oe,dagger,40,【攻击3】【攻击3】
dart,weapon,飞镖,o,dart,30,【攻击1】抓一张牌
crossbow,weapon,十字弩,onrersrw,crossbow,120,【攻击6】对同一目标打出其他十字弩
shield,armor,盾,oesw,shield,50,【防御3】
hat,armor,斗笠,oerwrn,hat,90,【防御8】
cape,armor,披风,oers,cape,45,【防御2】下回合【防御2】
bracer,armor,护腕,o,bracer,25,【防御1】抓1张牌
greatshield,armor,大盾,oesswn,greatshield,70,【防御5】
chainmail,armor,锁子甲,oesw,chainmail,60,本回合受到伤害-3
bandage,consumable,绷带,o,bandage,20,从牌堆或弃牌堆随机移除1张伤口
poisonPotion,consumable,淬毒药剂,o,poisonPotion,30,周围物品的【攻击】+2
fortifyPotion,consumable,强固药剂,o,fortifyPotion,30,周围物品的【防御】+2
vitalityPotion,consumable,活力药剂,o,vitalityPotion,25,获得1点能量
focusPotion,consumable,集中药剂,o,focusPotion,25,抓2张牌
healingPotion,consumable,治疗药剂,o,healingPotion,35,从牌堆或弃牌堆移除3张伤口
waterBag,tool,水袋,os,waterBag,35,下回合开始时获得1能量抓2张牌
rope,tool,绳索,ose,rope,30,周围物品的牌【防御】+2直到打出
belt,tool,腰带,owre,belt,40,从牌堆周围物品的牌当中选择一张加入手牌
torch,tool,火把,on,torch,25,下次打出周围物品的牌时将其消耗并获得1能量
whetstone,tool,磨刀石,o,whetstone,30,周围物品的牌【攻击】+3直到打出
blacksmithHammer,tool,铁匠锤,oerwrs,blacksmithHammer,45,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
1 id type name shape card price description
2 string string string string @card int string
3 sword weapon oee sword 50 【攻击2】【攻击2】
4 greataxe weapon 长斧 oees greataxe 80 对全体【攻击5】
5 spear weapon 长枪 oeee spear 75 【攻击2】【攻击2】【攻击2】
6 dagger weapon 短刀 oe dagger 40 【攻击3】【攻击3】
7 dart weapon 飞镖 o dart 30 【攻击1】抓一张牌
8 crossbow weapon 十字弩 onrersrw crossbow 120 【攻击6】对同一目标打出其他十字弩
9 shield armor oesw shield 50 【防御3】
10 hat armor 斗笠 oerwrn hat 90 【防御8】
11 cape armor 披风 oers cape 45 【防御2】下回合【防御2】
12 bracer armor 护腕 o bracer 25 【防御1】抓1张牌
13 greatshield armor 大盾 oesswn greatshield 70 【防御5】
14 chainmail armor 锁子甲 oesw chainmail 60 本回合受到伤害-3
15 bandage consumable 绷带 o bandage 20 从牌堆或弃牌堆随机移除1张伤口
16 poisonPotion consumable 淬毒药剂 o poisonPotion 30 周围物品的【攻击】+2
17 fortifyPotion consumable 强固药剂 o fortifyPotion 30 周围物品的【防御】+2
18 vitalityPotion consumable 活力药剂 o vitalityPotion 25 获得1点能量
19 focusPotion consumable 集中药剂 o focusPotion 25 抓2张牌
20 healingPotion consumable 治疗药剂 o healingPotion 35 从牌堆或弃牌堆移除3张伤口
21 waterBag tool 水袋 os waterBag 35 下回合开始时获得1能量抓2张牌
22 rope tool 绳索 ose rope 30 周围物品的牌【防御】+2直到打出
23 belt tool 腰带 owre belt 40 从牌堆周围物品的牌当中选择一张加入手牌
24 torch tool 火把 on torch 25 下次打出周围物品的牌时将其消耗并获得1能量
25 whetstone tool 磨刀石 o whetstone 30 周围物品的牌【攻击】+3直到打出
26 blacksmithHammer tool 铁匠锤 oerwrs blacksmithHammer 45 从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌

View File

@ -0,0 +1,16 @@
import type { Card } from './card.csv';
type ItemTable = readonly {
readonly id: string;
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: Card;
readonly price: number;
readonly description: string;
}[];
export type Item = ItemTable[number];
declare function getData(): ItemTable;
export default getData;

View File

@ -0,0 +1,33 @@
import {Triggers} from "@/samples/slay-the-spire-like/system/combat/triggers";
import {getCombatEntity} from "@/samples/slay-the-spire-like/system/combat/effects";
export function addEffectTriggers(triggers:Triggers){
// instant effects
triggers.onEffectApplied.use(async (ctx, next) => {
if(ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.entityKey,
amount: ctx.stacks
});
}else if(ctx.effect.id === "draw") {
await triggers.onDraw.execute(ctx.game, {
count: ctx.stacks
});
}
await next();
});
// blocks
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if(!entity) return;
const preventable = (ctx.amount - (ctx.prevented ?? 0));
const blocks = entity.effects.block?.stacks ?? 0;
const blocked = Math.min(blocks, preventable);
if(blocked){
ctx.prevented = (ctx.prevented ?? 0) + blocked;
}
await next();
});
}

View File

@ -0,0 +1,6 @@
import {addEffectTriggers} from './effect';
import {Triggers} from "@/samples/slay-the-spire-like/system/combat/triggers";
export function addTriggers(triggers: Triggers){
addEffectTriggers(triggers);
}

View File

@ -1,11 +0,0 @@
type EffectDesertTable = readonly {
readonly id: string;
readonly name: string;
readonly description: string;
readonly timing: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
}[];
export type EffectDesert = EffectDesertTable[number];
declare function getData(): EffectDesertTable;
export default getData;

View File

@ -1,36 +0,0 @@
# minion (10): minor enemies
# elite (4): dangerous enemies
# event (1): random dangerous event that requires reaction
# shop (2): merchant who sells different stuff
# camp (2): consumable restock and heal
# curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; hp; bonusHp] - bonusHp for scaling
type,name,description,enemies,dialogue
'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[string; int; int][],string
minion,仙人掌怪,概念:防+强化。【尖刺X】对攻击者造成X点伤害。,[仙人掌怪;20;0];[仙人掌怪;20;0],
minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌1费打出时移除此牌。弃掉时受到3点伤害。,[蛇;14;0];[蛇;14;0],
minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1直到弃掉一张该物品的牌。,[木乃伊;18;0];[仙人掌怪;20;0],
minion,枪手,概念单回高攻。【瞄准X】造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;0],
minion,风卷草,概念:防+强化。【滚动X】攻击时每消耗10点【滚动】造成等量伤害。,[风卷草;22;0];[风卷草;22;0],
minion,秃鹫,概念:攻+防。若造成伤害玩家获得秃鹫之眼0费状态牌打出时移除。抓到时获得3层暴露。,[秃鹫;16;0];[仙人掌怪;20;0],
minion,沙蝎,概念:攻+强化。【尾刺X】姿态buff攻击时伤害提升X。,[沙蝎;14;0];[蛇;14;0],
minion,幼沙虫,概念:防+强化。每回合第一次受伤时玩家失去1点能量。,[幼沙虫;24;0],
minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;0];[蜥蜴;20;0],
minion,沙匪,概念弱化玩家。【劫掠】对玩家施加的延时debuff。回合开始时随机弃掉一张手牌。,[沙匪;16;0];[枪手;16;0],
elite,风暴之灵,【风暴X】攻击时玩家获得1张静电。受伤时失去等量【风暴】。静电在手里时受【电击】伤害+1,[风暴之灵;44;0],
elite,骑马枪手,【冲锋X】受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;0];[枪手;20;4],
elite,沙虫王,召唤幼体沙虫每当玩家弃掉一张牌恢复1生命。,[沙虫王;55;0],
elite,沙漠守卫,召唤木乃伊会复活木乃伊2次。,[沙漠守卫;48;0];[木乃伊;20;0],
shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
camp,绿洲篝火,篝火可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
curio,枯井,投入1能量可能获得药水或什么也没有。,,desert_dry_well
curio,古代石碑,阅读碑文获得随机Buff直到下次战斗结束。,,desert_ancient_stele
curio,沙暴残骸,搜索残骸随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
curio,蜃景宝箱,打开宝箱50%获得宝藏50%为蜃景什么也没有。,,desert_mirage_chest
curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
curio,风化雕像,献祭1生命获得一件随机遗物。,,desert_weathered_statue
curio,绿洲碎片,小型绿洲恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
event,海市蜃楼,随机遭遇可能获得宝藏或遭遇陷阱使用d6双阶段结构结算。,,desert_mirage_event
1 # minion (10): minor enemies
2 # elite (4): dangerous enemies
3 # event (1): random dangerous event that requires reaction
4 # shop (2): merchant who sells different stuff
5 # camp (2): consumable restock and heal
6 # curio (8): random pickup of treasure or resources
7 # enemies: array of [enemyId; hp; bonusHp] - bonusHp for scaling
8 type,name,description,enemies,dialogue
9 'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[string; int; int][],string
10 minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;0];[仙人掌怪;20;0],
11 minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;0];[蛇;14;0],
12 minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;0];[仙人掌怪;20;0],
13 minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;0],
14 minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;0];[风卷草;22;0],
15 minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;0];[仙人掌怪;20;0],
16 minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;0];[蛇;14;0],
17 minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;0],
18 minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;0];[蜥蜴;20;0],
19 minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;0];[枪手;16;0],
20 elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;0],
21 elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;0];[枪手;20;4],
22 elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;0],
23 elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;0];[木乃伊;20;0],
24 shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
25 shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
26 camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
27 camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
28 curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
29 curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
30 curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
31 curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
32 curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
33 curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
34 curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
35 curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
36 event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event

View File

@ -1,12 +0,0 @@
type EncounterDesertTable = readonly {
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
readonly enemies: readonly [string, number, number];
readonly dialogue: string;
}[];
export type EncounterDesert = EncounterDesertTable[number];
declare function getData(): EncounterDesertTable;
export default getData;

View File

@ -1,16 +0,0 @@
import type { EffectDesert } from './effectDesert.csv';
type EnemyDesertTable = readonly {
readonly enemy: string;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [EffectDesert, readonly stacks: number];
readonly effects: readonly ["self" | "player" | "team", EffectDesert, number];
}[];
export type EnemyDesert = EnemyDesertTable[number];
declare function getData(): EnemyDesertTable;
export default getData;

View File

@ -1,26 +0,0 @@
type,name,shape,card,price
string,string,string,@cardDesert,int
weapon,剑,oee,sword,50
weapon,长斧,oees,greataxe,80
weapon,长枪,oeee,spear,75
weapon,短刀,oe,dagger,40
weapon,飞镖,o,dart,30
weapon,十字弩,onrersrw,crossbow,120
armor,盾,oesw,shield,50
armor,斗笠,oerwrn,hat,90
armor,披风,oers,cape,45
armor,护腕,o,bracer,25
armor,大盾,oesswn,greatshield,70
armor,锁子甲,oesw,chainmail,60
consumable,绷带,o,bandage,20
consumable,淬毒药剂,o,poisonPotion,30
consumable,强固药剂,o,fortifyPotion,30
consumable,活力药剂,o,vitalityPotion,25
consumable,集中药剂,o,focusPotion,25
consumable,治疗药剂,o,healingPotion,35
tool,水袋,os,waterBag,35
tool,绳索,ose,rope,30
tool,腰带,owre,belt,40
tool,火把,on,torch,25
tool,磨刀石,o,whetstone,30
tool,铁匠锤,oerwrs,blacksmithHammer,45
1 type name shape card price
2 string string string @cardDesert int
3 weapon oee sword 50
4 weapon 长斧 oees greataxe 80
5 weapon 长枪 oeee spear 75
6 weapon 短刀 oe dagger 40
7 weapon 飞镖 o dart 30
8 weapon 十字弩 onrersrw crossbow 120
9 armor oesw shield 50
10 armor 斗笠 oerwrn hat 90
11 armor 披风 oers cape 45
12 armor 护腕 o bracer 25
13 armor 大盾 oesswn greatshield 70
14 armor 锁子甲 oesw chainmail 60
15 consumable 绷带 o bandage 20
16 consumable 淬毒药剂 o poisonPotion 30
17 consumable 强固药剂 o fortifyPotion 30
18 consumable 活力药剂 o vitalityPotion 25
19 consumable 集中药剂 o focusPotion 25
20 consumable 治疗药剂 o healingPotion 35
21 tool 水袋 os waterBag 35
22 tool 绳索 ose rope 30
23 tool 腰带 owre belt 40
24 tool 火把 on torch 25
25 tool 磨刀石 o whetstone 30
26 tool 铁匠锤 oerwrs blacksmithHammer 45

View File

@ -1,14 +0,0 @@
import type { CardDesert } from './cardDesert.csv';
type HeroItemFighter1Table = readonly {
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: CardDesert;
readonly price: number;
}[];
export type HeroItemFighter1 = HeroItemFighter1Table[number];
declare function getData(): HeroItemFighter1Table;
export default getData;

View File

@ -1,16 +1,5 @@
import heroItemFighter1Csv from './heroItemFighter1.csv';
import encounterDesertCsv from './encounterDesert.csv';
import enemyDesertCsv from './enemyDesert.csv';
import effectDesertCsv from './effectDesert.csv';
import cardDesertCsv from './cardDesert.csv';
import * as desert from './desert';
export const heroItemFighter1Data = heroItemFighter1Csv();
export const encounterDesertData = encounterDesertCsv();
export const enemyDesertData = enemyDesertCsv();
export const effectDesertData = effectDesertCsv();
export const cardDesertData = cardDesertCsv();
export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv';
export { default as enemyDesertCsv, type EnemyDesert } from './enemyDesert.csv';
export { default as effectDesertCsv, type EffectDesert } from './effectDesert.csv';
export { default as cardDesertCsv, type CardDesert } from './cardDesert.csv';
export default {
desert
}

View File

@ -1,72 +0,0 @@
# 战斗规则
## 战斗状态
角色表:
- 敌方角色表
- 我方角色表
角色状态:
- hp/最大hp
- buff表名称->层数同名buff合并叠加
战利品表:
- 类型
- 数量
触发规则表:
- 类型
- 层数
## 战斗开始
战斗开始时,首先进行敌方角色的战斗开始结算,然后进行玩家的战斗开始结算。
战斗开始会让玩家抓5张起始手牌让敌方角色初始化意图。
## 回合
进行一个玩家回合,然后进行一个敌方回合,以此重复,直到不存在玩家或敌方角色存活。
玩家回合包含以下阶段:
- buff更新temporary buff清空lingering buff层数-1。
- 回合开始:触发回合开始结算的效果。
- 玩家行动:玩家可以花费能量打出手牌。点结束来结束行动。
- 回合结束:触发回合结束结算的效果。
- 重置手牌弃掉剩余的手牌重新抓5张手牌。
- 重置能量重置到3点能量。
敌人回合包含以下阶段:
- buff更新每个敌人的temporary buff清空lingering buff层数-1。
- 回合开始:触发回合开始结算的效果。
- 敌人行动:每个敌人的意图依次生效,然后更新下一个意图。
- 回合结束:触发回合结束结算的效果。
## 效果
效果结算时,具有以下上下文:
- name: 效果名称
- stacks: 层数
- target: 目标角色,如果有
- source来源角色如果有
- card来源卡牌如果有
效果有以下类型:
- Instant: 立即结算。
- Buff: 施加为buff持续整场战斗。
- BuffTemporary: 施加为buff下次buff更新时清空。
- BuffLingering: 施加为buff下次buff更新时层数-1。
- BuffPosture: 施加为buff受到伤害时扣除等量层数。
- Item: 施加为物品buff对来源卡牌对应的物品周围的物品生效。
- ItemUntilPlayed施加为物品buff对来源卡牌对应的物品生效生效一次后失效。
- ItemTemporary: 施加为物品buff对来源卡牌对应的物品生效下次buff更新时清空。
- ItemPermanent: 施加为物品buff对来源卡牌对应的物品生效战斗结束后依然有效。
- Card: 施加为状态卡牌,洗入玩家弃牌堆。对敌人无效。
- CardDraw洗入抓牌堆。
- CardHand加入手牌。

View File

@ -1,188 +0,0 @@
import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import { createRegion, createRegionAxis } from '@/core/region';
import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
import { cardDesertData } from '../data';
/**
* Generates a unique card ID for a cell within an item.
*/
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
/**
* Collects all cell keys from an item's shape in a deterministic order.
* Iterates the shape grid row by row, left to right, top to bottom.
*/
function getItemCells(item: InventoryItem<GameItemMeta>): CellKey[] {
const cells: CellKey[] = [];
const { shape, transform } = item;
const { grid } = shape;
const { offset, rotation, flipX, flipY } = transform;
// Track local dimensions (may swap on rotation)
let localWidth = grid[0]?.length || 1;
let localHeight = grid.length;
for (let gy = 0; gy < grid.length; gy++) {
for (let gx = 0; gx < grid[gy].length; gx++) {
if (!grid[gy][gx]) continue;
// Start from grid coordinates
let x = gx;
let y = gy;
// Apply rotation (90 degree increments, clockwise)
const rotTimes = ((rotation % 4) + 4) % 4;
for (let r = 0; r < rotTimes; r++) {
const newX = localHeight - 1 - y;
const newY = x;
x = newX;
y = newY;
// Swap dimensions for next iteration
const tmp = localWidth;
localWidth = localHeight;
localHeight = tmp;
}
// Reset local dimensions for fresh computation per cell
localWidth = grid[0]?.length || 1;
localHeight = grid.length;
// Apply flips
if (flipX) {
x = -x;
}
if (flipY) {
y = -y;
}
// Apply offset
const finalX = x + offset.x;
const finalY = y + offset.y;
cells.push(`${finalX},${finalY}`);
}
}
return cells;
}
/**
* Creates a single card from an inventory item.
*/
function createItemCard(
itemId: string,
itemData: GameItemMeta['itemData'],
cellKey: CellKey,
cellIndex: number
): GameCard {
const cardId = generateCardId(itemId, cellIndex);
const cardData = cardDesertData.find(c => c.id === itemData.card.id);
return {
id: cardId,
regionId: '',
position: [],
sourceItemId: itemId,
itemData: cardData ?? null,
cellKey,
displayName: cardData?.name ?? itemData.name,
description: cardData?.desc ?? '',
};
}
/**
* Creates a status card that does not correspond to any inventory item.
* Status cards represent temporary effects like wounds, stuns, etc.
*/
function createStatusCard(
id: string,
displayName: string,
description: string
): GameCard {
return {
id,
regionId: '',
position: [],
sourceItemId: null,
itemData: null,
cellKey: null,
displayName,
description,
};
}
/**
* Generates a complete player deck from the current inventory state.
*/
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const drawPile: string[] = [];
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
// Generate one card per occupied cell in the item's shape
const cellCount = item.shape.count;
const cells = getItemCells(item);
for (let i = 0; i < cellCount; i++) {
const cellKey = cells[i] ?? `${i},0`;
const card = createItemCard(item.id, itemData, cellKey, i);
cards[card.id] = card;
drawPile.push(card.id);
}
}
return {
cards,
drawPile,
hand: [],
discardPile: [],
exhaustPile: [],
};
}
/**
* Creates region definitions for deck management.
*/
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', [
createRegionAxis('index', 0, 0),
]),
hand: createRegion('hand', [
createRegionAxis('index', 0, 0),
]),
discardPile: createRegion('discardPile', [
createRegionAxis('index', 0, 0),
]),
exhaustPile: createRegion('exhaustPile', [
createRegionAxis('index', 0, 0),
]),
};
}
/**
* Creates an empty player deck structure.
*/
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
drawPile: [],
hand: [],
discardPile: [],
exhaustPile: [],
};
}
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
};

View File

@ -1,74 +0,0 @@
import type { Part } from '@/core/part';
import type { Region } from '@/core/region';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import type { CardDesert } from '../data/cardDesert.csv';
import type { CellKey } from '../grid-inventory/types';
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
/**
* Source item instance ID that this card was generated from.
* `null` for status cards (e.g. wound, stun) that don't correspond to an inventory item.
*/
sourceItemId: string | null;
/**
* Original card data from cardDesert.csv. `null` for status cards not in the CSV.
*/
itemData: CardDesert | null;
/**
* The cell key ("x,y") this card represents within the source item's shape.
* `null` for status cards.
*/
cellKey: CellKey | null;
/**
* Display name of the card.
* For item cards: derived from itemData.name.
* For status cards: custom name (e.g. "伤口", "眩晕").
*/
displayName: string;
/**
* Card description / ability text.
* For item cards: derived from itemData.desc.
* For status cards: custom description.
*/
description: string;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
/** Card IDs in the draw pile */
drawPile: string[];
/** Card IDs in the player's hand */
hand: string[];
/** Card IDs in the discard pile */
discardPile: string[];
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: string[];
}
/**
* Region structure for deck management.
*/
export interface DeckRegions {
/** Draw pile region */
drawPile: Region;
/** Hand region */
hand: Region;
/** Discard pile region */
discardPile: Region;
/** Exhaust pile region */
exhaustPile: Region;
}

View File

@ -1 +0,0 @@
export {default as encounters} from './encounters/encounters.yarnproject';

View File

@ -0,0 +1,41 @@
# 文件结构
## 系统实现
- `system/`:系统实现。
- `combat/`:管理一次战斗内的战斗状态、流程、触发。
- `progress/`:管理一次冒险的进度。
- `map/`:管理地图生成。
- `grid-inventory/`:管理背包状态。
- `deck/`:管理卡组状态。
## 数据
- `data/`:所有的数据文件
- `desert/`:游戏的测试模组,沙漠区域的内容。
- `card.csv`:所有的卡牌定义。
- `effect.csv`:所有的效应定义。
- `encounter.csv`:所有的遭遇定义。
- `enemy.csv`:所有的敌人和意图定义。
- `item.csv`:所有的玩家物品定义。
- `dialogues/`yarn对话项目目录
- `dialogues.yarnproject`yarn对话项目入口
- `**.yarn`具体的yarn对话文件代码不关心
- `index.ts`:统一导出所有的数据。
- `trigger.ts`:额外的触发器脚本,用于实现测试模组中涉及的效果。
## 开发流程
### SRD / 系统设计
首先编写srd文档。srd文档描述游戏系统和规则并提供少量内联的基础游戏数据。
SRD是系统实现的基础系统实现只能基于SRD不能依赖具体的游戏内容。
### Setting / 环境设定
使用SRD提供的特性来设计环境内容
- 效应:效应在技术上不依赖其他类型内容,可以首先确定。但在创意上可能不会直接设计效应,而是从若干创意中提取共性,并创建一个效应来作为主题。
- 卡牌:部分效应会创建卡牌或修改卡牌。在设计时需要注意。
- 敌人和意图:结合效应与主题表达设计敌人。通常需要为每个敌人设计独特的效应。
- 玩家物品:结合效应与主题表达设计物品。常见物品的效应容易找到替代,不常见物品则有特殊的效应。

View File

@ -1,147 +0,0 @@
// Data
export { heroItemFighter1Data, encounterDesertData, enemyDesertData, effectDesertData, cardDesertData } from './data';
export { default as encounterDesertCsv } from './data/encounterDesert.csv';
export type { EncounterDesert, CardDesert } from './data';
// Deck
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './deck';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './deck';
// Grid Inventory
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory';
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './grid-inventory';
// Map
export { MapNodeType, MapLayerType } from './map';
export type { MapNode, MapLayer, PointCrawlMap } from './map';
export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map';
// Progress Manager
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './progress';
export {
addGold,
addItemFromCsv,
canMoveTo,
createRunState,
damagePlayer,
getReachableChildren,
getCurrentNode,
getUnresolvedChildren,
healPlayer,
isAtEndNode,
isAtStartNode,
isEncounterResolved,
moveToNode,
removeItem as removeItemFromRun,
resolveEncounter,
setMaxHp,
spendGold,
} from './progress';
// Utils - Parse Shape
export type { ParsedShape } from './utils/parse-shape';
export { parseShapeString } from './utils/parse-shape';
// Utils - Shape Collision
export type { Point2D, Transform2D } from './utils/shape-collision';
export {
IDENTITY_TRANSFORM,
getOccupiedCells,
transformPoint,
transformShape,
checkCollision,
checkBoardCollision,
checkBounds,
validatePlacement as validateShapePlacement,
rotateTransform,
flipXTransform,
flipYTransform,
} from './utils/shape-collision';
// Combat
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from './combat';
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from './combat';
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
runCombat,
} from './combat';

View File

@ -0,0 +1,109 @@
import {CombatEntity, CombatState, EffectTable, PlayerEntity} from "./types";
import {CardData, EffectData} from "@/samples/slay-the-spire-like/system/types";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress/types";
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){
let current = effects[effect.id];
if(!current) current = {data: effect, stacks};
else current.stacks += stacks;
if(current.stacks === 0 && effects[effect.id])
delete effects[effect.id];
else if(current.stacks !== 0 && !effects[effect.id])
effects[effect.id] = current;
}
export function addEntityEffect(entity: CombatEntity, effect: EffectData, stacks: number){
addEffect(entity.effects, effect, stacks);
}
export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
addEffect(entity.itemEffects[itemKey], effect, stacks);
}
export function onEntityEffectUpkeep(entity: CombatEntity){
for(const effect of Object.values(entity.effects)){
const lifecycle = effect.data.lifecycle;
if(lifecycle === 'temporary')
addEntityEffect(entity, effect.data, -effect.stacks);
else if(lifecycle === 'lingering')
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1);
}
}
export function onEntityPostureDamage(entity: CombatEntity, damage: number){
for(const effect of Object.values(entity.effects)){
const lifecycle = effect.data.lifecycle;
if(lifecycle === 'posture')
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks));
}
}
export function onPlayerItemEffectUpkeep(entity: PlayerEntity){
for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){
for(const effect of Object.values(itemEffects)){
const lifecycle = effect.data.lifecycle;
if(lifecycle === 'itemTemporary')
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function onItemPlay(entity: PlayerEntity, itemKey: string){
const effects = entity.itemEffects[itemKey];
if(!effects)return;
for(const effect of Object.values(effects)){
if(effect.data.lifecycle === 'itemUntilPlay'){
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function onItemDiscard(entity: PlayerEntity, itemKey: string){
const effects = entity.itemEffects[itemKey];
if(!effects)return;
for(const effect of Object.values(effects)){
if(effect.data.lifecycle === 'itemUntilDiscard'){
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
}
}
}
export function* getAliveEnemies(state: CombatState) {
for (let enemy of state.enemies) {
if (enemy.isAlive) {
yield enemy;
}
}
}
export function getCombatEntity(state: CombatState, entityKey: string){
return entityKey === 'player' ? state.player : state.enemies.find(e => e.id === entityKey);
}
export function canPlayCard(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): boolean {
if (costType === 'energy') {
return player.energy >= costCount;
}
if (costType === 'uses') {
const item = inventory.items.get(itemId);
if (!item || !item.meta) return false;
const depletion = item.meta.depletion ?? 0;
return depletion < costCount;
}
return true;
}
export function payCardCost(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): void {
if (costType === 'energy') {
player.energy -= costCount;
} else if (costType === 'uses') {
const item = inventory.items.get(itemId);
if (item && item.meta) {
item.meta.depletion = (item.meta.depletion ?? 0) + costCount;
}
}
}

View File

@ -0,0 +1,43 @@
import { createPromptDef } from "@/core/game";
import {CombatGameContext} from "./types";
import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects";
export const prompts = {
mainAction: createPromptDef<[string, string?]>(
"main-action <cardId:string> [targetId:string]",
"选择卡牌并指定目标"
),
};
export async function promptMainAction(game: CombatGameContext){
return await game.prompt(prompts.mainAction, (cardId, targetId) => {
if(cardId === 'end-turn') return {
action: 'end-turn' as 'end-turn'
};
const exists = game.value.player.deck.regions.hand.childIds.includes(cardId);
if(!exists) throw `卡牌"${cardId}"不在手牌中`;
const card = game.value.player.deck.cards[cardId];
const {cardData, itemId} = card;
if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){
throw `无法支付卡牌"${cardId}"的费用`;
}
const {targetType} = cardData;
if(targetType === 'single'){
if(!targetId) throw `请指定目标`;
const target = game.value.enemies.find(e => e.id === targetId);
if(!target) throw `目标"${targetId}"不存在`;
if(!target.isAlive) throw `目标"${targetId}"已死亡`;
}else if(targetType === 'none'){
if(targetId) throw `目标"${targetId}"无效`;
}
return {
action: 'play' as 'play',
cardId,
targetId
};
});
}

View File

@ -0,0 +1,229 @@
import {CombatGameContext} from "./types";
import {
addEntityEffect,
addItemEffect,
getAliveEnemies, onEntityPostureDamage,
onEntityEffectUpkeep,
onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost
} from "@/samples/slay-the-spire-like/system/combat/effects";
import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts";
import {moveToRegion, shuffle} from "@/core/region";
import {createMiddlewareChain} from "@/utils/middleware";
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
type TriggerTypes = {
onCombatStart: {},
onTurnStart: { entityKey: "player" | string, },
onTurnEnd: { entityKey: "player" | string, },
onShuffle: {},
onCardPlayed: { cardId: string, targetId?: string },
onCardDiscarded: { cardId: string, },
onCardDrawn: { cardId: string, },
onDraw: {count: number},
onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string },
onHpChange: { entityKey: "player" | string, amount: number},
onDamage: { entityKey: "player" | string, amount: number, prevented?: number},
onEnemyIntent: { enemyId: string },
onIntentUpdate: { enemyId: string },
}
function createTriggers(){
const triggers = {
onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async ctx => {
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(entity) onEntityEffectUpkeep(entity);
if(entity === draft.player)
onPlayerItemEffectUpkeep(draft.player);
})
}),
onTurnEnd: createTrigger("onTurnEnd", async ctx => {
if(ctx.entityKey !== "player")return;
const {regions} = ctx.game.value.player.deck;
for(const cardId of Object.values(regions.hand.childIds)){
await triggers.onCardDiscarded.execute(ctx.game,{cardId});
}
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy);
await triggers.onDraw.execute(ctx.game,{count: 5});
}),
onShuffle: createTrigger("onShuffle", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
for(const cardId of Object.values(regions.discardPile.childIds))
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
shuffle(regions.drawPile, cards, ctx.game.rng);
});
}),
onCardPlayed: createTrigger("onCardPlayed", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
const card = cards[ctx.cardId];
payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory);
moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId);
});
}),
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
});
}),
onCardDrawn: createTrigger("onCardDrawn", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
});
}),
onDraw: createTrigger("onDraw", async ctx => {
let toDraw = ctx.count;
while(toDraw > 0){
let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if(inDraw <= 0) await triggers.onShuffle.execute(ctx.game,{});
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if(inDraw <= 0) break;
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
const cardId = children[children.length - 1];
await triggers.onCardDrawn.execute(ctx.game,{cardId});
toDraw--;
}
}),
onEffectApplied: createTrigger("onEffectApplied", async ctx => {
if(ctx.effect.lifecycle === 'instant') return;
if(ctx.effect.lifecycle.startsWith("item")) {
if(ctx.cardId){
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId);
for(const itemId of nearby.keys()){
await ctx.game.produceAsync(draft => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
});
}
}
return;
}
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
})
}),
onHpChange: createTrigger("onHpChange", async ctx => {
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(!entity) return;
entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0;
draft.result = !draft.player.isAlive ? "defeat" : draft.enemies.every(e => !e.isAlive) ? "victory" : null;
});
if(ctx.game.value.result) throw ctx.game.value;
}),
onDamage: createTrigger("onDamage", async ctx => {
const entity = ctx.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey);
if(!entity || !entity.isAlive) return;
const dealt = Math.min(Math.max(0,entity.hp), ctx.amount - (ctx.prevented || 0));
await ctx.game.produceAsync(draft => {
onEntityPostureDamage(entity, dealt);
});
await triggers.onHpChange.execute(ctx.game,{entityKey: ctx.entityKey, amount: -dealt});
}),
onEnemyIntent: createTrigger("onEnemyIntent", async ctx => {
const enemy = ctx.game.value.enemies.find(e => e.id === ctx.enemyId);
if(!enemy || !enemy.isAlive) return;
const intent = enemy.intents[enemy.currentIntentId];
if(!intent) return;
for(const [target, effect, stacks] of intent.effects){
if(target === 'team'){
for(const enemy of getAliveEnemies(ctx.game.value)){
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: enemy.id,
stacks,
});
}
}else {
const entityKey = target === 'self' ? ctx.enemyId : 'player';
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey,
stacks,
});
}
}
}),
onIntentUpdate: createTrigger("onIntentUpdate", async ctx => {
await ctx.game.produceAsync(draft => {
const enemy = draft.enemies.find(e => e.id === ctx.enemyId);
if(!enemy) return;
const intent = enemy.intents[enemy.currentIntentId];
if(!intent) return;
const nextIntents = intent.nextIntents;
if(nextIntents.length > 0){
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
enemy.currentIntentId = nextIntents[nextIndex];
}
});
}),
}
return triggers;
}
export type Triggers = ReturnType<typeof createTriggers>
export function createStartWith(build: (triggers: Triggers) => void){
const triggers = createTriggers();
build(triggers);
return async function(game: CombatGameContext){
await triggers.onCombatStart.execute(game,{});
try {
while (true) {
await triggers.onTurnStart.execute(game, {entityKey: "player"});
while (true) {
const action = await promptMainAction(game);
if (action.action === "end-turn") break;
if (action.action === "play") {
await triggers.onCardPlayed.execute(game, action);
}
}
await triggers.onTurnEnd.execute(game, {entityKey: "player"});
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnStart.execute(game, {entityKey: enemy.id});
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onEnemyIntent.execute(game, {enemyId: enemy.id});
await triggers.onIntentUpdate.execute(game, {enemyId: enemy.id});
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnEnd.execute(game, {entityKey: enemy.id});
}
}
}catch(e){
if(e === game.value) return game.value.result;
throw e;
}
}
}
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext };
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) {
const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback);
return {
use,
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
const param = {...ctx, game, event};
await execute(param);
return param;
},
}
}

View File

@ -0,0 +1,53 @@
import type { PlayerDeck } from "../deck/types";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types";
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
export type EffectTable = Record<string, {data: EffectData, stacks: number}>;
export type CombatEntity = {
effects: EffectTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type PlayerEntity = CombatEntity & {
energy: number;
maxEnergy: number;
deck: PlayerDeck;
itemEffects: Record<string, EffectTable>;
}
export type EnemyEntity = CombatEntity & {
id: string;
enemy: EnemyData;
intents: Record<string, IntentData>;
currentIntentId: string;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
type: "gold";
amount: number;
} | {
type: "item",
itemId: string;
};
export type CombatState = {
enemies: EnemyEntity[];
player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;

View File

@ -0,0 +1,66 @@
import {moveToRegion } from '@/core/region';
import { createRegion } from '@/core/region';
import type { GridInventory } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import type { CardData } from '../types';
import type {DeckRegions, GameCard, PlayerDeck} from './types';
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: '',
position: [],
itemId,
cardData
};
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
};
}
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
const count = item.shape.count;
for (let i = 0; i < count; i++) {
const card = createCard(item.id, itemData.card, i);
cards[card.id] = card;
moveToRegion(card, null, regions.drawPile);
}
}
return {
cards,
regions
};
}
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
regions: createDeckRegions(),
};
}
export {
generateDeckFromInventory,
createCard,
createPlayerDeck,
createDeckRegions,
generateCardId,
};

View File

@ -1,8 +1,7 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createCard,
createPlayerDeck,
generateCardId,
} from './factory';

View File

@ -0,0 +1,39 @@
import type { Part } from '@/core/part';
import {CardData} from "@/samples/slay-the-spire-like/system/types";
import {Region} from "@/core/region";
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
cardData: CardData;
itemId: string;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
regions: DeckRegions;
}
export interface DeckRegions{
/** Card IDs in the draw pile */
drawPile: Region;
/** Card IDs in the player's hand */
hand: Region;
/** Card IDs in the discard pile */
discardPile: Region;
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: Region;
}

View File

@ -197,7 +197,7 @@ export function getItemAtCell<TMeta = Record<string, unknown>>(
* Gets all items adjacent to the given item (orthogonally, not diagonally).
* Returns a Map of itemId -> item for deduplication.
*/
export function getAdjacentItems<TMeta extends Record<string, unknown> = Record<string, unknown>>(
export function getAdjacentItems<TMeta>(
inventory: GridInventory<TMeta>,
itemId: string
): Map<string, InventoryItem<TMeta>> {

View File

@ -18,7 +18,7 @@ export interface CellCoordinate {
* An item placed on the grid inventory.
* @template TMeta - Optional metadata type for game-specific data
*/
export interface InventoryItem<TMeta = Record<string, unknown>> {
export interface InventoryItem<TMeta> {
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
@ -44,7 +44,7 @@ export type MutationResult = { success: true } | { success: false; reason: strin
* Designed to be mutated directly inside a `mutative .produce()` callback.
* @template TMeta - Optional metadata type for items
*/
export interface GridInventory<TMeta = Record<string, unknown>> {
export interface GridInventory<TMeta> {
/** Board width in cells */
width: number;
/** Board height in cells */

View File

@ -1,16 +1,11 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import encounterDesertCsvAccessor, { type EncounterDesert } from '../data/encounterDesert.csv';
import { ReadonlyRNG } from '@/utils/rng';
import { MapNodeType, MapLayerType } from './types';
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
const encounterDesertCsv = encounterDesertCsvAccessor();
/** Pre-indexed encounters by type */
const encountersByType = buildEncounterIndex();
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
const index = new Map<string, EncounterDesert[]>();
for (const encounter of encounterDesertCsv) {
function buildEncounterIndex(src: Iterable<EncounterData>): Map<string, EncounterData[]> {
const index = new Map<string, EncounterData[]>();
for (const encounter of src) {
const type = encounter.type;
if (!index.has(type)) {
index.set(type, []);
@ -63,7 +58,7 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
* Fisher-Yates shuffle algorithm for unbiased random permutation.
* Mutates the array in place and returns it.
*/
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
function fisherYatesShuffle<T>(array: T[], rng: ReadonlyRNG): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
@ -75,11 +70,7 @@ function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
* Picks a random encounter for the given node type.
* Returns undefined if no matching encounter exists.
*/
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
if (!encounterType) return undefined;
const pool = encountersByType.get(encounterType);
function pickEncounterForNode(pool: EncounterData[] | undefined, rng: ReadonlyRNG): EncounterData | undefined {
if (!pool || pool.length === 0) return undefined;
return pool[rng.nextInt(pool.length)];
@ -98,12 +89,9 @@ const TOTAL_LAYERS = 10;
* - Each settlement layer has at least 1 of each: camp, shop, curio
* - Wild nodes connect to 1 wild node or 2 settlement nodes
*
* @param seed Random seed for reproducibility
*/
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
const rng = new Mulberry32RNG(seed ?? Date.now());
const actualSeed = rng.getSeed();
export function generatePointCrawlMap(rng: ReadonlyRNG, src: Iterable<EncounterData>): PointCrawlMap {
const encounters = buildEncounterIndex(src);
const layers: MapLayer[] = [];
const nodes = new Map<string, MapNode>();
@ -136,13 +124,13 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
for (let j = 0; j < structure.count; j++) {
const id = `node-${i}-${j}`;
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
const encounter = pickEncounterForNode(type, rng);
const encounter = pickEncounterForNode(encounters.get(NODE_TYPE_TO_ENCOUNTER[type]!), rng);
const node: MapNode = {
id,
layerIndex: i,
type,
childIds: [],
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
encounter
};
nodes.set(id, node);
nodeIds.push(id);
@ -171,7 +159,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
}
}
return { layers, nodes, seed: actualSeed, parentIndex };
return { layers, nodes, parentIndex };
}
/**
@ -181,7 +169,7 @@ function resolveNodeType(
layerType: MapLayerType | 'start' | 'end',
_nodeIndex: number,
_layerCount: number,
rng: RNG,
rng: ReadonlyRNG,
preGeneratedTypes?: MapNodeType[],
nodeIndex?: number,
settlementTypes?: MapNodeType[],
@ -213,7 +201,7 @@ function resolveNodeType(
* Picks a random type for a wild node based on configured weights.
* Default: minion: 50%, elite: 25%, event: 25%
*/
function pickWildNodeType(rng: RNG): MapNodeType {
function pickWildNodeType(rng: ReadonlyRNG): MapNodeType {
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
const roll = rng.nextInt(100);
@ -226,7 +214,7 @@ function pickWildNodeType(rng: RNG): MapNodeType {
* Generates random types for a pair of wild layers (3 nodes each).
* Returns two arrays of 3 node types each.
*/
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
function generateWildPair(rng: ReadonlyRNG): [MapNodeType[], MapNodeType[]] {
const layer1Types: MapNodeType[] = [];
const layer2Types: MapNodeType[] = [];
@ -284,7 +272,7 @@ function countRepetitions(
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
*/
function generateOptimalWildPair(
rng: RNG,
rng: ReadonlyRNG,
attempts = 3
): [MapNodeType[], MapNodeType[]] {
let bestLayer1: MapNodeType[] = [];
@ -313,7 +301,7 @@ function generateOptimalWildPair(
* The 4th node is randomly chosen from the three.
* Returns shuffled array of 4 node types.
*/
function generateSettlementTypes(rng: RNG): MapNodeType[] {
function generateSettlementTypes(rng: ReadonlyRNG): MapNodeType[] {
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
const randomType = requiredTypes[rng.nextInt(3)];
const types = [...requiredTypes, randomType];
@ -325,7 +313,7 @@ function generateSettlementTypes(rng: RNG): MapNodeType[] {
* The 4th node is randomly chosen from the three.
* @deprecated Use generateSettlementTypes() during node creation instead.
*/
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: ReadonlyRNG): void {
// Shuffle node order to randomize which position gets which type
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
@ -347,7 +335,7 @@ function generateLayerEdges(
sourceLayer: MapLayer,
targetLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG
rng: ReadonlyRNG
): void {
// Settlement types are now pre-generated during node creation
// No need to assign them here anymore
@ -402,7 +390,7 @@ function connectWildToWild(
function connectWildToSettlement(
wildLayer: MapLayer,
settlementLayer: MapLayer,
_rng: RNG
_rng: ReadonlyRNG
): void {
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
@ -427,7 +415,7 @@ function connectWildToSettlement(
function connectSettlementToWild(
settlementLayer: MapLayer,
wildLayer: MapLayer,
_rng: RNG
_rng: ReadonlyRNG
): void {
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
// This pattern guarantees no crossings because when edges are sorted by

View File

@ -1,3 +1,5 @@
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
/**
* Types of nodes that can appear on the point crawl map.
*/
@ -33,10 +35,7 @@ export interface MapNode {
/** IDs of nodes in the next layer this node connects to */
childIds: string[];
/** Encounter data assigned to this node (from encounter CSV) */
encounter?: {
name: string;
description: string;
};
encounter?: EncounterData;
}
/**
@ -61,8 +60,6 @@ export interface PointCrawlMap {
layers: MapLayer[];
/** All nodes keyed by ID */
nodes: Map<string, MapNode>;
/** RNG seed used for generation (for reproducibility) */
seed: number;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>;
}

View File

@ -1,13 +1,11 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import { generatePointCrawlMap, getNode } from '../map/generator';
import type { MapNode } from '../map/types';
import { getNode } from '../map/generator';
import type {MapNode, PointCrawlMap} from '../map/types';
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
import type { GridInventory } from '../grid-inventory/types';
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import { heroItemFighter1Data } from '../data';
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
// Re-export types
export type {
@ -28,21 +26,13 @@ const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
/** Starter items to give the player at the beginning of a run. */
const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑'];
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
* @param seed RNG seed for reproducibility. If omitted, uses current timestamp.
* @param rng Optional RNG instance for controlled randomness (overrides seed).
*/
export function createRunState(seed?: number, rng?: RNG): RunState {
const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31);
const map = generatePointCrawlMap(actualSeed);
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
// Find the start node
const startNode = map.layers[0].nodes[0];
@ -51,10 +41,7 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
const idCounter = { value: 0 };
// Place starter items
for (const itemName of STARTER_ITEM_NAMES) {
const itemData = findItemByName(itemName);
if (!itemData) continue;
for (const itemData of starterItems) {
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
@ -63,7 +50,6 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
}
return {
seed: actualSeed,
map,
player: {
maxHp: DEFAULT_MAX_HP,
@ -215,9 +201,9 @@ export function spendGold(runState: RunState, amount: number): RunMutationResult
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItemFromCsv(
export function addItem(
runState: RunState,
itemData: HeroItemFighter1
itemData: ItemData
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
@ -277,13 +263,6 @@ export function isAtEndNode(runState: RunState): boolean {
// -- Internal helpers --
/**
* Finds a hero item by name from the CSV data.
*/
function findItemByName(name: string): HeroItemFighter1 | undefined {
return heroItemFighter1Data.find(item => item.name === name);
}
/**
* Generates a unique item instance ID.
*/
@ -298,7 +277,7 @@ function generateInstanceId(counter: { value: number }): string {
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: HeroItemFighter1,
itemData: ItemData,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {

View File

@ -1,7 +1,7 @@
import type { PointCrawlMap } from '../map/types';
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
import type { ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
/**
* Result of an encounter (combat, event, etc.).
@ -35,9 +35,11 @@ export interface EncounterState {
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: HeroItemFighter1;
itemData: ItemData;
/** Parsed shape for grid placement */
shape: ParsedShape;
/** Consumed uses, if card cost type is uses**/
depletion?: number;
}
/**
@ -63,8 +65,6 @@ export interface PlayerState {
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** RNG seed used for map generation */
seed: number;
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */

View File

@ -0,0 +1,61 @@
export type EffectData = {
readonly id: string;
readonly name: string;
readonly description: string;
readonly lifecycle: EffectLifecycle;
};
export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
export type EnemyData = {
readonly id: string;
readonly name: string;
readonly description: string;
};
export type CardType = "item" | "status";
export type CardCostType = "energy" | "uses" | "none";
export type CardTargetType = "single" | "none";
export type EffectTarget = "self" | "player" | "team";
export type CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
};
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random"
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio";
export type EncounterData = {
readonly id: string;
readonly type: EncounterType;
readonly name: string;
readonly description: string;
readonly enemies: readonly [EnemyData, number, readonly [effect: EffectData, stacks: number]];
readonly dialogue: string;
};
export type IntentData = {
readonly enemy: EnemyData;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [EffectData, stacks: number];
readonly effects: readonly [EffectTarget, EffectData, number][];
};
export type ItemData = {
readonly id: string;
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: CardData;
readonly price: number;
readonly description: string;
};

34
src/utils/middleware.ts Normal file
View File

@ -0,0 +1,34 @@
type Middleware<TContext, TReturn> = (
context: TContext,
next: () => Promise<TReturn>
) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void;
execute: (context: TContext) => Promise<TReturn>;
};
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
fallback?: (context: TContext) => Promise<TReturn>
): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = [];
return {
use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware);
},
async execute(context: TContext) {
let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn;
}
const current = middlewares[index++];
return current(ctx, () => dispatch(ctx));
}
return dispatch(context);
},
};
}

View File

@ -1,344 +1,550 @@
import { describe, it, expect } from 'vitest';
import {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
addEffect,
addEntityEffect,
addItemEffect,
onEntityEffectUpkeep,
onEntityPostureDamage,
onPlayerItemEffectUpkeep,
onItemPlay,
onItemDiscard,
getAliveEnemies,
getCombatEntity,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
getModifiedAttackDamage,
getModifiedDefendAmount,
} from '@/samples/slay-the-spire-like/combat/effects';
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
import {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
} from '@/samples/slay-the-spire-like/combat/state';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { enemyDesertData, encounterDesertData } from '@/samples/slay-the-spire-like/data';
import { Mulberry32RNG } from '@/utils/rng';
payCardCost,
} from '@/samples/slay-the-spire-like/system/combat/effects';
import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
return { id, name: id, description: '', lifecycle };
}
function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) {
return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const };
}
function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem<GameItemMeta> {
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc: '测试',
id: itemId,
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D,
meta: {
itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' },
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
depletion: costType === 'uses' ? depletion : undefined,
},
shape,
};
}
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
const meta1 = createTestMeta('短刀', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'item-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
function createInventory(items: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
const map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>();
for (const item of items) {
map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`);
}
return { width: 6, height: 4, items: map, occupiedCells: occupied };
}
function createCombatEntity(hp = 10, maxHp = 10): CombatEntity {
return {
effects: {},
hp,
maxHp,
isAlive: hp > 0,
};
placeItem(inv, item1);
return inv;
}
function createTestCombatState(): CombatState {
const inv = createTestInventory();
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
return createCombatState(playerState, inv, encounter);
function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
return {
...createCombatEntity(hp, maxHp),
energy: 3,
maxEnergy: 3,
deck: { cards: {}, regions: { drawPile: { id: 'drawPile', axes: [], childIds: [], partMap: {} }, hand: { id: 'hand', axes: [], childIds: [], partMap: {} }, discardPile: { id: 'discardPile', axes: [], childIds: [], partMap: {} }, exhaustPile: { id: 'exhaustPile', axes: [], childIds: [], partMap: {} } } },
itemEffects: {},
};
}
function createSimpleRng() {
return new Mulberry32RNG(42);
function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
return {
...createCombatEntity(hp, maxHp),
id,
enemy: { id, name: id, description: '' },
intents: {},
currentIntentId: '',
};
}
function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState {
return {
player: createPlayerEntity(playerHp),
enemies,
inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() },
phase: 'playerTurn',
turnNumber: 1,
result: null,
loot: [],
};
}
describe('combat/effects', () => {
describe('applyDamage', () => {
it('should deal damage to player', () => {
const state = createTestCombatState();
applyDamage(state, 'player', 10);
describe('addEffect', () => {
it('should add a new effect to an empty table', () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
expect(state.player.hp).toBe(40);
expect(state.player.damageTakenThisTurn).toBe(10);
expect(state.player.damagedThisTurn).toBe(true);
addEffect(table, effect, 3);
expect(table['strength']).toBeDefined();
expect(table['strength'].data).toBe(effect);
expect(table['strength'].stacks).toBe(3);
});
it('should deal damage to enemy', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
const enemy = state.enemies[enemyId];
const initialHp = enemy.hp;
it('should stack with existing effect of same id', () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'lingering');
applyDamage(state, enemyId, 5);
addEffect(table, effect, 2);
addEffect(table, effect, 3);
expect(enemy.hp).toBe(initialHp - 5);
expect(table['strength'].stacks).toBe(5);
});
it('should be absorbed by defend buff on player', () => {
const state = createTestCombatState();
state.player.buffs['defend'] = 3;
it('should remove effect when stacks reach 0', () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
const result = applyDamage(state, 'player', 5);
addEffect(table, effect, 3);
addEffect(table, effect, -3);
expect(result.blockedByDefend).toBe(3);
expect(result.damageDealt).toBe(2);
expect(state.player.hp).toBe(48);
expect(table['strength']).toBeUndefined();
});
it('should be fully absorbed by defend buff', () => {
const state = createTestCombatState();
state.player.buffs['defend'] = 10;
it('should not add effect when stacks is 0', () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
applyDamage(state, 'player', 5);
addEffect(table, effect, 0);
expect(state.player.hp).toBe(50);
expect(state.player.buffs['defend']).toBe(5);
expect(table['strength']).toBeUndefined();
});
it('should be absorbed by defend buff on enemy', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['defend'] = 4;
it('should handle negative stacks', () => {
const table: EffectTable = {};
const effect = createEffect('weak', 'temporary');
const result = applyDamage(state, enemyId, 6);
addEffect(table, effect, -2);
expect(result.blockedByDefend).toBe(4);
expect(result.damageDealt).toBe(2);
expect(state.enemies[enemyId].hp).toBe(state.enemies[enemyId].maxHp - 2);
});
it('should mark defend broken when defend fully consumed', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['defend'] = 3;
applyDamage(state, enemyId, 5);
expect(state.enemies[enemyId].hadDefendBroken).toBe(true);
});
it('should kill enemy when HP reaches 0', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
applyDamage(state, enemyId, state.enemies[enemyId].maxHp);
expect(state.enemies[enemyId].isAlive).toBe(false);
expect(state.enemies[enemyId].hp).toBe(0);
});
it('should not deal negative damage', () => {
const state = createTestCombatState();
const result = applyDamage(state, 'player', -5);
expect(result.damageDealt).toBe(0);
expect(state.player.hp).toBe(50);
});
it('should apply damageReduce buff', () => {
const state = createTestCombatState();
state.player.buffs['damageReduce'] = 3;
applyDamage(state, 'player', 5);
expect(state.player.hp).toBe(48);
expect(table['weak'].stacks).toBe(-2);
});
});
describe('applyDefend', () => {
it('should add defend stacks', () => {
const buffs: Record<string, number> = {};
applyDefend(buffs, 5);
describe('addEntityEffect', () => {
it('should add effect to entity.effects', () => {
const entity = createCombatEntity();
const effect = createEffect('vulnerable', 'lingering');
expect(buffs['defend']).toBe(5);
});
addEntityEffect(entity, effect, 2);
it('should stack with existing defend', () => {
const buffs: Record<string, number> = { defend: 3 };
applyDefend(buffs, 4);
expect(buffs['defend']).toBe(7);
expect(entity.effects['vulnerable'].stacks).toBe(2);
});
});
describe('applyBuff / removeBuff', () => {
it('should apply buff stacks', () => {
const buffs: Record<string, number> = {};
applyBuff(buffs, 'aim', 'lingering', 3);
describe('addItemEffect', () => {
it('should add effect to player.itemEffects[itemKey]', () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
expect(buffs['aim']).toBe(3);
addItemEffect(player, 'sword-1', effect, 3);
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(3);
});
it('should stack existing buffs', () => {
const buffs: Record<string, number> = { aim: 2 };
applyBuff(buffs, 'aim', 'lingering', 3);
it('should initialize itemEffects entry if not present', () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
expect(buffs['aim']).toBe(5);
addItemEffect(player, 'new-item', effect, 1);
expect(player.itemEffects['new-item']).toBeDefined();
});
it('should remove buff partially', () => {
const buffs: Record<string, number> = { aim: 5 };
const removed = removeBuff(buffs, 'aim', 3);
it('should stack with existing item effect', () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
expect(removed).toBe(3);
expect(buffs['aim']).toBe(2);
});
addItemEffect(player, 'sword-1', effect, 2);
addItemEffect(player, 'sword-1', effect, 3);
it('should remove buff fully when stacks exceed current', () => {
const buffs: Record<string, number> = { aim: 2 };
const removed = removeBuff(buffs, 'aim', 10);
expect(removed).toBe(2);
expect(buffs['aim']).toBeUndefined();
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
});
});
describe('updateBuffs', () => {
it('should clear temporary buffs', () => {
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
updateBuffs(buffs);
describe('onEntityEffectUpkeep', () => {
it('should remove temporary effects', () => {
const entity = createCombatEntity();
const tempEffect = createEffect('temp-shield', 'temporary');
expect(buffs['damageReduce']).toBeUndefined();
expect(buffs['defendNext']).toBeUndefined();
addEntityEffect(entity, tempEffect, 5);
onEntityEffectUpkeep(entity);
expect(entity.effects['temp-shield']).toBeUndefined();
});
it('should decrement lingering buffs', () => {
const buffs: Record<string, number> = { curse: 3, energyDrain: 1 };
updateBuffs(buffs);
it('should decrement lingering effects by 1', () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering');
expect(buffs['curse']).toBe(2);
expect(buffs['energyDrain']).toBeUndefined();
addEntityEffect(entity, lingeringEffect, 3);
onEntityEffectUpkeep(entity);
expect(entity.effects['poison'].stacks).toBe(2);
});
it('should not affect permanent or posture buffs', () => {
const buffs: Record<string, number> = { defend: 5, spike: 1 };
updateBuffs(buffs);
it('should remove lingering effects when stacks reach 0', () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering');
expect(buffs['defend']).toBe(5);
expect(buffs['spike']).toBe(1);
addEntityEffect(entity, lingeringEffect, 1);
onEntityEffectUpkeep(entity);
expect(entity.effects['poison']).toBeUndefined();
});
it('should not affect permanent effects', () => {
const entity = createCombatEntity();
const permEffect = createEffect('max-hp-up', 'permanent');
addEntityEffect(entity, permEffect, 5);
onEntityEffectUpkeep(entity);
expect(entity.effects['max-hp-up'].stacks).toBe(5);
});
it('should not affect instant effects', () => {
const entity = createCombatEntity();
const instantEffect = createEffect('instant-damage', 'instant');
addEntityEffect(entity, instantEffect, 10);
onEntityEffectUpkeep(entity);
expect(entity.effects['instant-damage'].stacks).toBe(10);
});
it('should increment lingering effects with negative stacks', () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('regen', 'lingering');
addEntityEffect(entity, lingeringEffect, -3);
onEntityEffectUpkeep(entity);
expect(entity.effects['regen'].stacks).toBe(-2);
});
});
describe('onEntityPostureDamage', () => {
it('should reduce posture effects by damage amount', () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
addEntityEffect(entity, postureEffect, 10);
onEntityPostureDamage(entity, 4);
expect(entity.effects['block'].stacks).toBe(6);
});
it('should not reduce posture effects below 0', () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
addEntityEffect(entity, postureEffect, 3);
onEntityPostureDamage(entity, 10);
expect(entity.effects['block']).toBeUndefined();
});
it('should not affect non-posture effects', () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
const permEffect = createEffect('strength', 'permanent');
addEntityEffect(entity, postureEffect, 5);
addEntityEffect(entity, permEffect, 3);
onEntityPostureDamage(entity, 2);
expect(entity.effects['block'].stacks).toBe(3);
expect(entity.effects['strength'].stacks).toBe(3);
});
it('should handle zero damage', () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
addEntityEffect(entity, postureEffect, 5);
onEntityPostureDamage(entity, 0);
expect(entity.effects['block'].stacks).toBe(5);
});
});
describe('onPlayerItemEffectUpkeep', () => {
it('should remove itemTemporary effects', () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
addItemEffect(player, 'sword-1', effect, 5);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['adjacent-buff']).toBeUndefined();
});
it('should not affect itemPermanent effects', () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemPermanent');
addItemEffect(player, 'sword-1', effect, 5);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
});
it('should not affect itemUntilPlay effects', () => {
const player = createPlayerEntity();
const effect = createEffect('charged', 'itemUntilPlay');
addItemEffect(player, 'sword-1', effect, 3);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3);
});
});
describe('onItemPlay', () => {
it('should remove itemUntilPlay effects', () => {
const player = createPlayerEntity();
const effect = createEffect('charged', 'itemUntilPlay');
addItemEffect(player, 'sword-1', effect, 3);
onItemPlay(player, 'sword-1');
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
});
it('should not affect other lifecycle effects', () => {
const player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent');
const playEffect = createEffect('charged', 'itemUntilPlay');
addItemEffect(player, 'sword-1', permEffect, 5);
addItemEffect(player, 'sword-1', playEffect, 3);
onItemPlay(player, 'sword-1');
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
});
it('should do nothing for item with no effects', () => {
const player = createPlayerEntity();
expect(() => onItemPlay(player, 'nonexistent')).not.toThrow();
});
});
describe('onItemDiscard', () => {
it('should remove itemUntilDiscard effects', () => {
const player = createPlayerEntity();
const effect = createEffect('discard-buff', 'itemUntilDiscard');
addItemEffect(player, 'sword-1', effect, 3);
onItemDiscard(player, 'sword-1');
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
});
it('should not affect other lifecycle effects', () => {
const player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent');
const discardEffect = createEffect('discard-buff', 'itemUntilDiscard');
addItemEffect(player, 'sword-1', permEffect, 5);
addItemEffect(player, 'sword-1', discardEffect, 3);
onItemDiscard(player, 'sword-1');
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
});
it('should do nothing for item with no effects', () => {
const player = createPlayerEntity();
expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow();
});
});
describe('getAliveEnemies', () => {
it('should yield only alive enemies', () => {
const state = createCombatState(30, [
createEnemyEntity('slime-1', 10, 10),
createEnemyEntity('slime-2', 0, 10),
createEnemyEntity('slime-3', 5, 10),
]);
const alive = [...getAliveEnemies(state)];
expect(alive.length).toBe(2);
expect(alive[0].id).toBe('slime-1');
expect(alive[1].id).toBe('slime-3');
});
it('should return empty for no enemies', () => {
const state = createCombatState(30, []);
const alive = [...getAliveEnemies(state)];
expect(alive.length).toBe(0);
});
it('should return empty when all enemies are dead', () => {
const state = createCombatState(30, [
createEnemyEntity('slime-1', 0, 10),
createEnemyEntity('slime-2', 0, 10),
]);
const alive = [...getAliveEnemies(state)];
expect(alive.length).toBe(0);
});
});
describe('getCombatEntity', () => {
it('should return player for "player" key', () => {
const state = createCombatState(30);
const entity = getCombatEntity(state, 'player');
expect(entity).toBe(state.player);
});
it('should return enemy by id', () => {
const enemy = createEnemyEntity('boss-1', 50, 50);
const state = createCombatState(30, [enemy]);
const entity = getCombatEntity(state, 'boss-1');
expect(entity).toBe(enemy);
});
it('should return undefined for non-existent enemy', () => {
const state = createCombatState(30, [createEnemyEntity('slime-1')]);
const entity = getCombatEntity(state, 'nonexistent');
expect(entity).toBeUndefined();
});
});
describe('canPlayCard', () => {
it('should allow playing card with enough energy', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
it('should allow playing energy card when player has enough energy', () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
const result = canPlayCard(state, cardId);
expect(result.canPlay).toBe(true);
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
expect(result).toBe(true);
});
it('should reject card not in hand', () => {
const state = createTestCombatState();
it('should reject playing energy card when player lacks energy', () => {
const player = createPlayerEntity();
player.energy = 1;
const inventory = createInventory([]);
const result = canPlayCard(state, 'nonexistent-card');
expect(result.canPlay).toBe(false);
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
expect(result).toBe(false);
});
it('should reject card with insufficient energy', () => {
const state = createTestCombatState();
state.player.energy = 0;
const cardId = state.player.deck.hand[0];
it('should allow playing uses card when item has remaining uses', () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
const inventory = createInventory([item]);
const card = state.player.deck.cards[cardId];
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
const result = canPlayCard(state, cardId);
expect(result.canPlay).toBe(false);
expect(result.reason).toBe('能量不足');
}
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
expect(result).toBe(true);
});
it('should reject playing uses card when item is depleted', () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 3);
const inventory = createInventory([item]);
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
expect(result).toBe(false);
});
it('should reject playing uses card when item not in inventory', () => {
const player = createPlayerEntity();
const inventory = createInventory([]);
const result = canPlayCard(player, 'uses', 1, 'missing', inventory);
expect(result).toBe(false);
});
it('should always allow playing none cost card', () => {
const player = createPlayerEntity();
player.energy = 0;
const inventory = createInventory([]);
const result = canPlayCard(player, 'none', 0, 'any', inventory);
expect(result).toBe(true);
});
});
describe('playCard', () => {
it('should deduct energy cost when playing card', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
const card = state.player.deck.cards[cardId];
const initialEnergy = state.player.energy;
describe('payCardCost', () => {
it('should deduct energy for energy cost card', () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
if (card?.itemData?.costType === 'energy') {
const ctx = { state, rng: createSimpleRng() };
const result = playCard(ctx, cardId);
payCardCost(player, 'energy', 2, 'any', inventory);
if (result.success) {
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
}
}
expect(player.energy).toBe(1);
});
it('should move card to discard pile after playing', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
const ctx = { state, rng: createSimpleRng() };
it('should increment depletion for uses cost card', () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
const inventory = createInventory([item]);
const result = playCard(ctx, cardId);
payCardCost(player, 'uses', 3, 'potion-1', inventory);
if (result.success) {
expect(state.player.deck.hand.includes(cardId)).toBe(false);
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
}
});
});
describe('areAllEnemiesDead / isPlayerDead', () => {
it('should detect all enemies dead', () => {
const state = createTestCombatState();
expect(areAllEnemiesDead(state)).toBe(false);
for (const enemyId of state.enemyOrder) {
state.enemies[enemyId].isAlive = false;
}
expect(areAllEnemiesDead(state)).toBe(true);
expect(item.meta?.depletion).toBe(4);
});
it('should detect player death', () => {
const state = createTestCombatState();
expect(isPlayerDead(state)).toBe(false);
it('should do nothing for none cost card', () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
state.player.hp = 0;
expect(isPlayerDead(state)).toBe(true);
});
});
payCardCost(player, 'none', 0, 'any', inventory);
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
it('should return base damage with no item buffs', () => {
const state = createTestCombatState();
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
expect(player.energy).toBe(3);
});
it('should return base defend with no item buffs', () => {
const state = createTestCombatState();
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
});
it('should handle missing item gracefully for uses cost', () => {
const player = createPlayerEntity();
const inventory = createInventory([]);
it('should add item buff attack damage', () => {
const state = createTestCombatState();
state.itemBuffs.push({
effectId: 'attackBuff',
stacks: 3,
timing: 'itemUntilPlayed',
sourceItemId: 'item-1',
targetItemId: 'item-1',
});
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow();
});
});
});

View File

@ -1,261 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createGameHost, GameHost } from '@/core/game-host';
import { createGameContext, createGameCommandRegistry } from '@/core/game';
import type { CombatState, CombatGameContext } from '@/samples/slay-the-spire-like/combat/types';
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
import { runCombat } from '@/samples/slay-the-spire-like/combat/procedure';
import { prompts } from '@/samples/slay-the-spire-like/combat/prompts';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc: '测试',
},
shape,
};
}
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
const meta1 = createTestMeta('短刀', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'item-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
};
placeItem(inv, item1);
return inv;
}
function createTestCombatState(): CombatState {
const inv = createTestInventory();
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
return createCombatState(playerState, inv, encounter);
}
function waitForPrompt(host: GameHost<CombatState>): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (host.activePromptSchema.value !== null) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
describe('combat/procedure', () => {
describe('runCombat with GameHost', () => {
it('should start combat and prompt for player action', async () => {
const registry = createGameCommandRegistry<CombatState>();
const initialState = createTestCombatState();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
expect(host.activePromptSchema.value).not.toBeNull();
expect(host.activePromptSchema.value?.name).toBe('play-card');
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should accept play-card input', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
const cardId = state.player.deck.hand[0];
const error = host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
expect(error).toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should reject invalid card play', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const error = host.tryAnswerPrompt(prompts.playCard, 'nonexistent-card');
expect(error).not.toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should transition to end-turn after playing cards', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
const cardId = state.player.deck.hand[0];
host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
await waitForPrompt(host);
expect(host.activePromptSchema.value).not.toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should accept end-turn', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const error = host.tryAnswerPrompt(prompts.endTurn);
expect(error).toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
});
describe('combat outcome', () => {
it('should return victory when all enemies are dead', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => {
const state = createTestCombatState();
for (const enemyId of state.enemyOrder) {
state.enemies[enemyId].hp = 1;
}
return state;
},
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
let iterations = 0;
while (host.status.value === 'running' && iterations < 100) {
const state = host.state.value;
if (host.activePromptSchema.value?.name === 'play-card') {
const cardId = state.player.deck.hand[0];
if (cardId) {
const targetId = state.enemyOrder.find(id => state.enemies[id].isAlive);
host.tryAnswerPrompt(prompts.playCard, cardId, targetId);
}
} else if (host.activePromptSchema.value?.name === 'end-turn') {
host.tryAnswerPrompt(prompts.endTurn);
}
await new Promise(r => setTimeout(r, 10));
iterations++;
}
if (host.status.value === 'running') {
host._context._commands._cancel();
try { await combatPromise; } catch {}
}
});
});
describe('combat state transitions', () => {
it('should track turn number across turns', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
host.tryAnswerPrompt(prompts.endTurn);
await waitForPrompt(host);
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should reset energy at start of player turn', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
expect(state.player.energy).toBe(state.player.maxEnergy);
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
});
});

View File

@ -1,303 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from '@/samples/slay-the-spire-like/combat/state';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { encounterDesertData, enemyDesertData, effectDesertData } from '@/samples/slay-the-spire-like/data';
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc: '测试物品',
},
shape,
};
}
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
const meta1 = createTestMeta('短刀', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'item-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
};
placeItem(inv, item1);
return inv;
}
function createTestPlayerState(): PlayerState {
return { maxHp: 50, currentHp: 50, gold: 0 };
}
describe('combat/state', () => {
describe('constants', () => {
it('should have correct default values', () => {
expect(INITIAL_HAND_SIZE).toBe(5);
expect(DEFAULT_MAX_ENERGY).toBe(3);
expect(FATIGUE_CARDS_PER_SHUFFLE).toBe(2);
});
});
describe('createEnemyInstance', () => {
it('should create enemy from desert data', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
expect(enemy.templateId).toBe('仙人掌怪');
expect(enemy.hp).toBe(cactusData.initHp);
expect(enemy.maxHp).toBe(cactusData.initHp);
expect(enemy.isAlive).toBe(true);
expect(enemy.hadDefendBroken).toBe(false);
});
it('should apply bonus HP', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 5, { value: 0 });
expect(enemy.hp).toBe(cactusData.initHp + 5);
expect(enemy.maxHp).toBe(cactusData.initHp + 5);
});
it('should initialize buffs from template', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
expect(enemy.buffs['spike']).toBe(1);
});
it('should set initial intent', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
expect(enemy.currentIntentId).toBe(cactusData.initialIntent);
});
it('should generate unique IDs', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const e1 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
const e2 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
expect(e1.id).not.toBe(e2.id);
});
});
describe('createPlayerCombatState', () => {
it('should create player state from run state and inventory', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
expect(combatPlayer.hp).toBe(50);
expect(combatPlayer.maxHp).toBe(50);
expect(combatPlayer.energy).toBe(DEFAULT_MAX_ENERGY);
expect(combatPlayer.maxEnergy).toBe(DEFAULT_MAX_ENERGY);
expect(Object.keys(combatPlayer.buffs).length).toBe(0);
expect(combatPlayer.damagedThisTurn).toBe(false);
expect(combatPlayer.cardsDiscardedThisTurn).toBe(0);
});
it('should generate deck from inventory', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
expect(Object.keys(combatPlayer.deck.cards).length).toBeGreaterThan(0);
expect(combatPlayer.deck.drawPile.length).toBeGreaterThan(0);
});
});
describe('createCombatState', () => {
it('should create full combat state from encounter', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
const combat = createCombatState(playerState, inv, encounter);
expect(combat.phase).toBe('playerTurn');
expect(combat.turnNumber).toBe(1);
expect(combat.result).toBeNull();
expect(combat.loot).toEqual([]);
expect(combat.fatigueAddedCount).toBe(0);
});
it('should create enemies from encounter data', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
const combat = createCombatState(playerState, inv, encounter);
expect(combat.enemyOrder.length).toBeGreaterThan(0);
for (const enemyId of combat.enemyOrder) {
expect(combat.enemies[enemyId].isAlive).toBe(true);
}
});
it('should draw initial hand', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
const combat = createCombatState(playerState, inv, encounter);
expect(combat.player.deck.hand.length).toBe(INITIAL_HAND_SIZE);
});
});
describe('drawCardsToHand', () => {
it('should draw cards from draw pile to hand', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
const initialDrawPile = combatPlayer.deck.drawPile.length;
const initialHand = combatPlayer.deck.hand.length;
drawCardsToHand(combatPlayer.deck, 3);
expect(combatPlayer.deck.hand.length).toBe(initialHand + 3);
expect(combatPlayer.deck.drawPile.length).toBe(initialDrawPile - 3);
});
});
describe('addFatigueCards', () => {
it('should add fatigue cards to draw pile', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
const initialCount = Object.keys(combatPlayer.deck.cards).length;
const fatigueCounter = { value: 0 };
addFatigueCards(combatPlayer.deck, FATIGUE_CARDS_PER_SHUFFLE, fatigueCounter);
expect(Object.keys(combatPlayer.deck.cards).length).toBe(initialCount + FATIGUE_CARDS_PER_SHUFFLE);
expect(fatigueCounter.value).toBe(FATIGUE_CARDS_PER_SHUFFLE);
});
it('should create fatigue cards with correct properties', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
addFatigueCards(combatPlayer.deck, 1, { value: 0 });
const fatigueCard = combatPlayer.deck.cards['fatigue-1'];
expect(fatigueCard).toBeDefined();
expect(fatigueCard.displayName).toBe('疲劳');
expect(fatigueCard.sourceItemId).toBeNull();
expect(fatigueCard.itemData).toBeNull();
});
});
describe('discardHand', () => {
it('should move all hand cards to discard pile', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
drawCardsToHand(combatPlayer.deck, 3);
const handCount = combatPlayer.deck.hand.length;
discardHand(combatPlayer.deck);
expect(combatPlayer.deck.hand).toEqual([]);
expect(combatPlayer.deck.discardPile.length).toBe(handCount);
});
});
describe('discardCard / exhaustCard', () => {
it('should move a card from hand to discard pile', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
drawCardsToHand(combatPlayer.deck, 3);
const cardId = combatPlayer.deck.hand[0];
discardCard(combatPlayer.deck, cardId);
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
expect(combatPlayer.deck.discardPile.includes(cardId)).toBe(true);
});
it('should move a card from hand to exhaust pile', () => {
const inv = createTestInventory();
const playerState = createTestPlayerState();
const combatPlayer = createPlayerCombatState(playerState, inv);
drawCardsToHand(combatPlayer.deck, 3);
const cardId = combatPlayer.deck.hand[0];
exhaustCard(combatPlayer.deck, cardId);
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
expect(combatPlayer.deck.exhaustPile.includes(cardId)).toBe(true);
});
});
describe('enemy intent', () => {
it('should get current intent', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
const intent = getEnemyCurrentIntent(enemy);
expect(intent).toBeDefined();
expect(intent!.id).toBe('boost');
});
it('should advance intent after action', () => {
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
const originalIntent = enemy.currentIntentId;
advanceEnemyIntent(enemy);
expect(enemy.currentIntentId).toBeDefined();
});
});
describe('getEffectTiming / getEffectData', () => {
it('should return timing for known effects', () => {
expect(getEffectTiming('attack')).toBe('instant');
expect(getEffectTiming('defend')).toBe('posture');
expect(getEffectTiming('spike')).toBe('permanent');
expect(getEffectTiming('energyDrain')).toBe('lingering');
});
it('should return undefined for unknown effects', () => {
expect(getEffectTiming('nonexistent')).toBeUndefined();
});
it('should return effect data for known effects', () => {
const data = getEffectData('attack');
expect(data).toBeDefined();
expect(data!.id).toBe('attack');
expect(data!.name).toBe('攻击');
});
});
});

View File

@ -1,254 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
} from '@/samples/slay-the-spire-like/combat/triggers';
import type { TriggerContext, CombatTriggerRegistry } from '@/samples/slay-the-spire-like/combat/triggers';
import type { CombatState } from '@/samples/slay-the-spire-like/combat/types';
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
import { Mulberry32RNG } from '@/utils/rng';
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc: '测试',
},
shape,
};
}
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
const meta1 = createTestMeta('短刀', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'item-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
};
placeItem(inv, item1);
return inv;
}
function createTestCombatState(): CombatState {
const inv = createTestInventory();
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
return createCombatState(playerState, inv, encounter);
}
function createTestTriggerCtx(state: CombatState): TriggerContext {
return { state, rng: new Mulberry32RNG(42) };
}
describe('combat/triggers', () => {
describe('createCombatTriggerRegistry', () => {
it('should create registry with desert zone triggers', () => {
const registry = createCombatTriggerRegistry();
expect(registry['spike']).toBeDefined();
expect(registry['aim']).toBeDefined();
expect(registry['charge']).toBeDefined();
expect(registry['roll']).toBeDefined();
expect(registry['tailSting']).toBeDefined();
expect(registry['energyDrain']).toBeDefined();
expect(registry['molt']).toBeDefined();
expect(registry['storm']).toBeDefined();
expect(registry['vultureEye']).toBeDefined();
expect(registry['venom']).toBeDefined();
expect(registry['static']).toBeDefined();
expect(registry['curse']).toBeDefined();
expect(registry['discard']).toBeDefined();
});
});
describe('spike trigger', () => {
it('should deal damage to attacker when enemy is attacked', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['spike'] = 2;
const initialPlayerHp = state.player.hp;
dispatchAttackedTrigger(ctx, 'player', enemyId, 5, registry);
expect(state.player.hp).toBeLessThan(initialPlayerHp);
});
});
describe('aim trigger', () => {
it('should double outgoing damage with aim stacks', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 3;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(10);
});
it('should not double damage with 0 aim stacks', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 0;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(5);
});
it('should lose aim stacks on damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 5;
dispatchDamageTrigger(ctx, enemyId, 3, registry);
expect(state.enemies[enemyId].buffs['aim']).toBe(2);
});
});
describe('charge trigger', () => {
it('should double outgoing and incoming damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['charge'] = 2;
const outDmg = dispatchOutgoingDamageTrigger(ctx, enemyId, 6, registry);
expect(outDmg).toBe(12);
const inDmg = dispatchIncomingDamageTrigger(ctx, enemyId, 6, registry);
expect(inDmg).toBe(12);
});
it('should lose charge stacks on damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['charge'] = 5;
dispatchDamageTrigger(ctx, enemyId, 3, registry);
expect(state.enemies[enemyId].buffs['charge']).toBe(2);
});
});
describe('roll trigger', () => {
it('should increase damage when roll >= 10', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['roll'] = 20;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(7);
expect(state.enemies[enemyId].buffs['roll']).toBe(0);
});
it('should not modify damage when roll < 10', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['roll'] = 5;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(5);
});
});
describe('tailSting trigger', () => {
it('should deal damage to player at turn end', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['tailSting'] = 3;
const initialHp = state.player.hp;
dispatchTrigger(ctx, 'onTurnEnd', enemyId, registry);
expect(state.player.hp).toBeLessThan(initialHp);
});
});
describe('static trigger', () => {
it('should increase incoming damage to player', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
state.player.buffs['static'] = 2;
const modified = dispatchIncomingDamageTrigger(ctx, 'player', 5, registry);
expect(modified).toBe(7);
});
});
describe('molt trigger', () => {
it('should cause enemy to flee when molt stacks >= maxHp', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['molt'] = state.enemies[enemyId].maxHp;
dispatchDamageTrigger(ctx, enemyId, 1, registry);
expect(state.enemies[enemyId].isAlive).toBe(false);
expect(state.result).toBe('fled');
});
it('should not cause flee when molt stacks < maxHp', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['molt'] = 1;
dispatchDamageTrigger(ctx, enemyId, 1, registry);
expect(state.enemies[enemyId].isAlive).toBe(true);
});
});
describe('dispatchTrigger with missing handler', () => {
it('should be a no-op for unknown buff', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['nonexistentBuff'] = 5;
expect(() => {
dispatchTrigger(ctx, 'onTurnStart', enemyId, registry);
}).not.toThrow();
});
});
});

View File

@ -1,311 +1,8 @@
import { describe, it, expect } from 'vitest';
import {
heroItemFighter1Data,
encounterDesertData,
enemyDesertData,
enemyIntentDesertData,
effectDesertData,
statusCardDesertData,
} from '@/samples/slay-the-spire-like/data';
import data from '@/samples/slay-the-spire-like/data';
describe('heroItemFighter1.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
});
it('should have expected number of items', () => {
expect(heroItemFighter1Data.length).toBe(24);
});
it('should have correct fields for each item', () => {
for (const item of heroItemFighter1Data) {
expect(item).toHaveProperty('type');
expect(item).toHaveProperty('name');
expect(item).toHaveProperty('shape');
expect(item).toHaveProperty('costType');
expect(item).toHaveProperty('costCount');
expect(item).toHaveProperty('targetType');
expect(item).toHaveProperty('desc');
expect(item).toHaveProperty('effects');
}
});
it('should parse costCount as number', () => {
for (const item of heroItemFighter1Data) {
expect(typeof item.costCount).toBe('number');
}
});
it('should contain expected items by name', () => {
const names = heroItemFighter1Data.map(item => item.name);
expect(names).toContain('剑');
expect(names).toContain('盾');
expect(names).toContain('绷带');
expect(names).toContain('火把');
});
it('should have valid type values', () => {
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
for (const item of heroItemFighter1Data) {
expect(validTypes).toContain(item.type);
}
});
it('should have valid costType values', () => {
const validCostTypes = ['energy', 'uses'];
for (const item of heroItemFighter1Data) {
expect(validCostTypes).toContain(item.costType);
}
});
it('should have valid targetType values', () => {
const validTargetTypes = ['single', 'none'];
for (const item of heroItemFighter1Data) {
expect(validTargetTypes).toContain(item.targetType);
}
});
it('should have correct item counts by type', () => {
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
acc[item.type] = (acc[item.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
expect(typeCounts['weapon']).toBe(6);
expect(typeCounts['armor']).toBe(6);
expect(typeCounts['consumable']).toBe(6);
expect(typeCounts['tool']).toBe(6);
});
it('should have effects with target, effect ref, and value', () => {
for (const item of heroItemFighter1Data) {
expect(Array.isArray(item.effects)).toBe(true);
for (const [target, effect, value] of item.effects) {
expect(target === 'self' || target === 'target' || target === 'all' || target === 'random').toBe(true);
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
expect(typeof value).toBe('number');
}
}
});
});
describe('encounterDesert.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(encounterDesertData)).toBe(true);
expect(encounterDesertData.length).toBeGreaterThan(0);
});
it('should have correct encounter type counts', () => {
const typeCounts = encounterDesertData.reduce((acc, e) => {
acc[e.type] = (acc[e.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
expect(typeCounts['minion']).toBe(10);
expect(typeCounts['elite']).toBe(4);
expect(typeCounts['shop']).toBe(2);
expect(typeCounts['camp']).toBe(2);
expect(typeCounts['curio']).toBe(8);
expect(typeCounts['event']).toBe(1);
});
it('should have enemies for combat encounters', () => {
for (const e of encounterDesertData) {
if (e.type === 'minion' || e.type === 'elite') {
expect(Array.isArray(e.enemies)).toBe(true);
expect(e.enemies.length).toBeGreaterThan(0);
for (const [enemy, bonusHp] of e.enemies) {
expect(typeof enemy === 'string' || typeof enemy === 'object').toBe(true);
expect(typeof bonusHp).toBe('number');
}
}
}
});
it('should have empty enemies for non-combat encounters', () => {
for (const e of encounterDesertData) {
if (e.type === 'shop' || e.type === 'camp') {
expect(e.enemies.length).toBe(0);
}
}
});
it('should have dialogue for curio and event encounters', () => {
for (const e of encounterDesertData) {
if (e.type === 'curio' || e.type === 'event') {
expect(e.dialogue).toBeTruthy();
expect(e.dialogue.startsWith('desert_')).toBe(true);
}
}
});
});
describe('effectDesert.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(effectDesertData)).toBe(true);
expect(effectDesertData.length).toBeGreaterThan(0);
});
it('should have expected number of effects', () => {
expect(effectDesertData.length).toBe(35);
});
it('should have correct fields for each effect', () => {
for (const effect of effectDesertData) {
expect(effect).toHaveProperty('id');
expect(effect).toHaveProperty('name');
expect(effect).toHaveProperty('description');
expect(effect).toHaveProperty('timing');
}
});
it('should have valid timing values', () => {
const validTimings = ['instant', 'temporary', 'lingering', 'permanent', 'posture', 'card', 'cardDraw', 'cardHand', 'item', 'itemUntilPlayed'];
for (const effect of effectDesertData) {
expect(validTimings).toContain(effect.timing);
}
});
it('should contain core effect types', () => {
const ids = effectDesertData.map(e => e.id);
expect(ids).toContain('attack');
expect(ids).toContain('defend');
expect(ids).toContain('spike');
expect(ids).toContain('venom');
expect(ids).toContain('draw');
expect(ids).toContain('removeWound');
expect(ids).toContain('gainEnergy');
});
});
describe('enemyDesert.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(enemyDesertData)).toBe(true);
expect(enemyDesertData.length).toBeGreaterThan(0);
});
it('should have expected number of enemies', () => {
expect(enemyDesertData.length).toBe(14);
});
it('should have correct fields for each enemy', () => {
for (const enemy of enemyDesertData) {
expect(enemy).toHaveProperty('id');
expect(enemy).toHaveProperty('initHp');
expect(enemy).toHaveProperty('initBuffs');
expect(enemy).toHaveProperty('initialIntent');
expect(typeof enemy.initHp).toBe('number');
expect(typeof enemy.initialIntent).toBe('string');
}
});
it('should have valid HP ranges', () => {
for (const enemy of enemyDesertData) {
expect(enemy.initHp).toBeGreaterThan(0);
}
});
it('should have minions with lower HP than elites', () => {
const minionIds = ['仙人掌怪', '蛇', '木乃伊', '枪手', '风卷草', '秃鹫', '沙蝎', '幼沙虫', '蜥蜴', '沙匪'];
const eliteIds = ['风暴之灵', '骑马枪手', '沙虫王', '沙漠守卫'];
const byId = Object.fromEntries(enemyDesertData.map(e => [e.id, e]));
for (const id of minionIds) {
expect(byId[id].initHp).toBeLessThan(40);
}
for (const id of eliteIds) {
expect(byId[id].initHp).toBeGreaterThanOrEqual(40);
}
});
});
describe('enemyIntentDesert.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(enemyIntentDesertData)).toBe(true);
expect(enemyIntentDesertData.length).toBeGreaterThan(0);
});
it('should have expected number of intent rows', () => {
expect(enemyIntentDesertData.length).toBe(41);
});
it('should have correct fields for each intent', () => {
for (const intent of enemyIntentDesertData) {
expect(intent).toHaveProperty('enemy');
expect(intent).toHaveProperty('id');
expect(intent).toHaveProperty('nextIntents');
expect(intent).toHaveProperty('brokenIntent');
expect(intent).toHaveProperty('effects');
expect(intent.enemy).toHaveProperty('id');
expect(Array.isArray(intent.nextIntents)).toBe(true);
expect(Array.isArray(intent.brokenIntent)).toBe(true);
expect(Array.isArray(intent.effects)).toBe(true);
}
});
it('should have effects with target, effect ref, and value', () => {
for (const intent of enemyIntentDesertData) {
for (const [target, effect, value] of intent.effects) {
expect(target === 'self' || target === 'player' || target === 'team').toBe(true);
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
expect(typeof value).toBe('number');
}
}
});
it('should cover all 14 enemies', () => {
const enemyIds = new Set(enemyIntentDesertData.map(i => typeof i.enemy === 'string' ? i.enemy : i.enemy.id));
expect(enemyIds.size).toBe(14);
});
});
describe('statusCardDesert.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(statusCardDesertData)).toBe(true);
expect(statusCardDesertData.length).toBeGreaterThan(0);
});
it('should have expected number of status cards', () => {
expect(statusCardDesertData.length).toBe(6);
});
it('should have correct fields for each status card', () => {
for (const card of statusCardDesertData) {
expect(card).toHaveProperty('id');
expect(card).toHaveProperty('name');
expect(card).toHaveProperty('desc');
expect(card).toHaveProperty('unplayable');
expect(card).toHaveProperty('effects');
expect(typeof card.id).toBe('string');
expect(typeof card.name).toBe('string');
expect(typeof card.desc).toBe('string');
expect(typeof card.unplayable).toBe('boolean');
}
});
it('should have all cards unplayable', () => {
for (const card of statusCardDesertData) {
expect(card.unplayable).toBe(true);
}
});
it('should have effects with target, effect ref, and value', () => {
for (const card of statusCardDesertData) {
expect(Array.isArray(card.effects)).toBe(true);
for (const [target, effect, value] of card.effects) {
expect(target).toBe('self');
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
expect(typeof value).toBe('number');
}
}
});
it('should contain expected status cards by id', () => {
const ids = statusCardDesertData.map(c => c.id);
expect(ids).toContain('wound');
expect(ids).toContain('venom');
expect(ids).toContain('curse');
expect(ids).toContain('static');
describe('data import', () => {
it('should import properly', () => {
expect(data.desert.effects).toBeDefined();
});
});

View File

@ -1,33 +1,57 @@
import { describe, it, expect } from 'vitest';
import {
generateDeckFromInventory,
createStatusCard,
createCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from '@/samples/slay-the-spire-like/deck/factory';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
} from '@/samples/slay-the-spire-like/system/deck/factory';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import type { CardData, ItemData } from '@/samples/slay-the-spire-like/system/types';
/**
* Helper: create a minimal CardData for testing.
*/
function createTestCardData(id: string, name: string, desc: string): CardData {
return {
id,
name,
desc,
type: 'item',
costType: 'energy',
costCount: 1,
targetType: 'single',
effects: [],
};
}
/**
* Helper: create a minimal ItemData for testing.
*/
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData {
return {
id,
type: 'weapon',
name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
price: 10,
description: desc,
};
}
/**
* Helper: create a minimal GameItemMeta for testing.
*/
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc,
},
itemData,
shape,
};
}
@ -70,21 +94,14 @@ describe('deck/factory', () => {
});
});
describe('createStatusCard', () => {
it('should create a card with null sourceItemId and itemData', () => {
const card = createStatusCard('wound-1', '伤口', '无法被弃牌');
expect(card.id).toBe('wound-1');
expect(card.sourceItemId).toBeNull();
expect(card.itemData).toBeNull();
expect(card.cellKey).toBeNull();
expect(card.displayName).toBe('伤口');
expect(card.description).toBe('无法被弃牌');
});
it('should have empty region and position', () => {
const card = createStatusCard('stun-1', '眩晕', '跳过出牌阶段');
describe('createCard', () => {
it('should create a card with itemId and cardData', () => {
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
const card = createCard('wound-1', cardData, 0);
expect(card.id).toBe('card-wound-1-0');
expect(card.itemId).toBe('wound-1');
expect(card.cardData).toBe(cardData);
expect(card.regionId).toBe('');
expect(card.position).toEqual([]);
});
@ -98,10 +115,10 @@ describe('deck/factory', () => {
const deck = generateDeckFromInventory(inv);
expect(Object.keys(deck.cards).length).toBe(6);
expect(deck.drawPile.length).toBe(6);
expect(deck.hand).toEqual([]);
expect(deck.discardPile).toEqual([]);
expect(deck.exhaustPile).toEqual([]);
expect(deck.regions.drawPile.childIds.length).toBe(6);
expect(deck.regions.hand.childIds).toEqual([]);
expect(deck.regions.discardPile.childIds).toEqual([]);
expect(deck.regions.exhaustPile.childIds).toEqual([]);
});
it('should link cards to their source items', () => {
@ -109,18 +126,18 @@ describe('deck/factory', () => {
const deck = generateDeckFromInventory(inv);
const daggerCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'dagger-1'
c => c.itemId === 'dagger-1'
);
const shieldCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'shield-1'
c => c.itemId === 'shield-1'
);
expect(daggerCards.length).toBe(2);
expect(shieldCards.length).toBe(4);
// Verify item data
expect(daggerCards[0].itemData?.name).toBe('短刀');
expect(shieldCards[0].itemData?.name).toBe('盾');
// Verify card data
expect(daggerCards[0].cardData.name).toBe('短刀');
expect(shieldCards[0].cardData.name).toBe('盾');
});
it('should set displayName and description from item data', () => {
@ -128,28 +145,28 @@ describe('deck/factory', () => {
const deck = generateDeckFromInventory(inv);
for (const card of Object.values(deck.cards)) {
expect(card.displayName).toBeTruthy();
expect(card.description).toBeTruthy();
expect(card.cardData.name).toBeTruthy();
expect(card.cardData.desc).toBeTruthy();
}
const daggerCard = Object.values(deck.cards).find(
c => c.itemData?.name === '短刀'
c => c.cardData.name === '短刀'
);
expect(daggerCard?.displayName).toBe('短刀');
expect(daggerCard?.description).toBe('【攻击3】【攻击3】');
expect(daggerCard?.cardData.name).toBe('短刀');
expect(daggerCard?.cardData.desc).toBe('【攻击3】【攻击3】');
});
it('should assign unique cell keys to each card from same item', () => {
it('should assign unique IDs to each card from same item', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
const daggerCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'dagger-1'
c => c.itemId === 'dagger-1'
);
const cellKeys = daggerCards.map(c => c.cellKey);
const uniqueKeys = new Set(cellKeys);
expect(uniqueKeys.size).toBe(cellKeys.length);
const ids = daggerCards.map(c => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should handle empty inventory', () => {
@ -157,19 +174,19 @@ describe('deck/factory', () => {
const deck = generateDeckFromInventory(inv);
expect(Object.keys(deck.cards).length).toBe(0);
expect(deck.drawPile).toEqual([]);
expect(deck.regions.drawPile.childIds).toEqual([]);
});
it('should place all cards in draw pile initially', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
for (const cardId of deck.drawPile) {
for (const cardId of deck.regions.drawPile.childIds) {
expect(deck.cards[cardId]).toBeDefined();
}
// All cards are in draw pile
expect(new Set(deck.drawPile).size).toBe(Object.keys(deck.cards).length);
expect(new Set(deck.regions.drawPile.childIds).size).toBe(Object.keys(deck.cards).length);
});
});
@ -198,10 +215,10 @@ describe('deck/factory', () => {
const deck = createPlayerDeck();
expect(deck.cards).toEqual({});
expect(deck.drawPile).toEqual([]);
expect(deck.hand).toEqual([]);
expect(deck.discardPile).toEqual([]);
expect(deck.exhaustPile).toEqual([]);
expect(deck.regions.drawPile.childIds).toEqual([]);
expect(deck.regions.hand.childIds).toEqual([]);
expect(deck.regions.discardPile.childIds).toEqual([]);
expect(deck.regions.exhaustPile.childIds).toEqual([]);
});
});
});

View File

@ -1,53 +0,0 @@
import { describe, it, expect } from 'vitest';
import encounters from '@/samples/slay-the-spire-like/dialogue/encounters/encounters.yarnproject';
describe('encounters.yarnproject import', () => {
it('should load the yarnproject with expected project metadata', () => {
expect(encounters.project.projectName).toBe('encounters');
expect(encounters.project.baseLanguage).toBe('en');
expect(encounters.project.authorName).toContain('hyper');
expect(encounters.project.projectFileVersion).toBe(4);
});
it('should have sourceFiles configured', () => {
expect(encounters.project.sourceFiles).toContain('**/*.yarn');
});
it('should have a valid baseDir', () => {
expect(typeof encounters.baseDir).toBe('string');
expect(encounters.baseDir.length).toBeGreaterThan(0);
});
it('should compile nodes from .yarn files', () => {
const nodeTitles = Object.keys(encounters.program.nodes);
expect(nodeTitles.length).toBeGreaterThan(0);
});
it('should contain expected nodes from story.yarn', () => {
const nodeTitles = Object.keys(encounters.program.nodes);
expect(nodeTitles).toContain('Start');
expect(nodeTitles).toContain('Scene1');
expect(nodeTitles).toContain('Scene2');
expect(nodeTitles).toContain('Scene3');
expect(nodeTitles).toContain('Scene4');
});
it('should have instructions in each node', () => {
for (const [title, node] of Object.entries(encounters.program.nodes)) {
if ('instructions' in node && Array.isArray((node as any).instructions)) {
expect((node as any).instructions.length).toBeGreaterThan(0);
}
}
});
it('should have Start node with jump instruction to Scene1', () => {
const startNode = encounters.program.nodes['Start'];
if ('instructions' in startNode) {
const jumpInstruction = startNode.instructions.find(
(instr) => instr.op === 'jump',
);
expect(jumpInstruction).toBeDefined();
expect(jumpInstruction!.target).toBe('Scene1');
}
});
});

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
import {
createGridInventory,
placeItem,
@ -14,7 +14,7 @@ import {
validatePlacement,
type GridInventory,
type InventoryItem,
} from '@/samples/slay-the-spire-like/grid-inventory';
} from '@/samples/slay-the-spire-like/system/grid-inventory';
/**
* Helper: create a test inventory item.

View File

@ -1,15 +1,17 @@
import { describe, it, expect } from 'vitest';
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/map/generator';
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/map/types';
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator';
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types';
import { createRNG } from '@/utils/rng';
import { encounters } from '@/samples/slay-the-spire-like/data/desert';
describe('generatePointCrawlMap', () => {
it('should generate a map with 10 layers', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
expect(map.layers.length).toBe(10);
});
it('should have correct layer structure', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedStructure = [
'start',
MapLayerType.Wild,
@ -29,7 +31,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have correct node counts per layer', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
for (let i = 0; i < expectedCounts.length; i++) {
@ -38,7 +40,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have Start and End nodes with correct types', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const startNode = map.nodes.get('node-0-0');
const endNode = map.nodes.get('node-9-0');
@ -47,7 +49,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have wild layers with minion/elite/event types', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
@ -62,7 +64,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) {
@ -77,7 +79,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have Start connected to all 3 wild nodes', () => {
const map = generatePointCrawlMap(42);
const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get('node-0-0');
const wildLayer = map.layers[1];
@ -86,7 +88,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
const map = generatePointCrawlMap(42);
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
@ -105,7 +107,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
const map = generatePointCrawlMap(42);
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
@ -125,7 +127,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
const map = generatePointCrawlMap(42);
const map = generatePointCrawlMap(createRNG(42), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
@ -153,7 +155,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have all 3 wild nodes connect to End', () => {
const map = generatePointCrawlMap(42);
const map = generatePointCrawlMap(createRNG(42), encounters);
const lastWildLayer = map.layers[8];
const endNode = map.nodes.get('node-9-0');
@ -164,7 +166,7 @@ describe('generatePointCrawlMap', () => {
});
it('should have all nodes reachable from Start and can reach End', () => {
const map = generatePointCrawlMap(123);
const map = generatePointCrawlMap(createRNG(123), encounters);
const startId = 'node-0-0';
const endId = 'node-9-0';
@ -176,7 +178,7 @@ describe('generatePointCrawlMap', () => {
});
it('should not have crossing edges in wild→wild transitions', () => {
const map = generatePointCrawlMap(12345);
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
@ -214,7 +216,7 @@ describe('generatePointCrawlMap', () => {
});
it('should not have crossing edges in wild→settlement transitions', () => {
const map = generatePointCrawlMap(12345);
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
@ -251,7 +253,7 @@ describe('generatePointCrawlMap', () => {
});
it('should not have crossing edges in settlement→wild transitions', () => {
const map = generatePointCrawlMap(12345);
const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
@ -288,7 +290,7 @@ describe('generatePointCrawlMap', () => {
});
it('should assign encounters to all non-Start/End nodes', () => {
const map = generatePointCrawlMap(456);
const map = generatePointCrawlMap(createRNG(456), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
@ -306,7 +308,7 @@ describe('generatePointCrawlMap', () => {
it('should assign encounters to all nodes across multiple seeds', () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(seed);
const map = generatePointCrawlMap(createRNG(seed), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
@ -321,7 +323,7 @@ describe('generatePointCrawlMap', () => {
it('should minimize same-layer repetitions in wild layer pairs', () => {
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const map = generatePointCrawlMap(12345);
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildPairIndices = [
[1, 2],
[4, 5],
@ -351,7 +353,7 @@ describe('generatePointCrawlMap', () => {
it('should minimize adjacent repetitions in wild→wild connections', () => {
// Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(12345);
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import {
checkCollision,
checkBoardCollision,
@ -8,7 +8,7 @@ import {
transformShape,
getOccupiedCells,
IDENTITY_TRANSFORM,
} from '@/samples/slay-the-spire-like/utils/shape-collision';
} from '@/samples/slay-the-spire-like/system/utils/shape-collision';
describe('parseShapeString', () => {
it('should parse a single cell with o', () => {

View File

@ -0,0 +1,436 @@
import { describe, it, expect } from 'vitest';
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware';
describe('createMiddlewareChain', () => {
describe('basic execution', () => {
it('should return context when no middlewares and no fallback', async () => {
const chain = createMiddlewareChain<{ value: number }>();
const result = await chain.execute({ value: 42 });
expect(result).toEqual({ value: 42 });
});
it('should call fallback when no middlewares', async () => {
const chain = createMiddlewareChain<{ value: number }, string>(
async ctx => `value is ${ctx.value}`
);
const result = await chain.execute({ value: 42 });
expect(result).toBe('value is 42');
});
it('should pass context to fallback', async () => {
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
async ctx => ctx.a + ctx.b
);
const result = await chain.execute({ a: 3, b: 7 });
expect(result).toBe(10);
});
});
describe('single middleware', () => {
it('should execute a single middleware', async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count *= 2;
return next();
});
const result = await chain.execute({ count: 5 });
expect(result.count).toBe(10);
});
it('should allow middleware to modify return value', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value
);
chain.use(async (ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ value: 21 });
expect(result).toBe(42);
});
it('should allow middleware to short-circuit without calling next', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, _next) => {
return { value: 999 };
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(999);
});
});
describe('multiple middlewares', () => {
it('should execute middlewares in order', async () => {
const order: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
order.push(1);
const result = await next();
order.push(4);
return result;
});
chain.use(async (_ctx, next) => {
order.push(2);
const result = await next();
order.push(3);
return result;
});
await chain.execute({ value: 0 });
expect(order).toEqual([1, 2, 3, 4]);
});
it('should accumulate modifications through the chain', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
ctx.value *= 2;
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 3;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(5);
});
it('should allow middleware to modify result on the way back', async () => {
const chain = createMiddlewareChain<{ base: number }, number>(
async ctx => ctx.base
);
chain.use(async (_ctx, next) => {
const result = await next();
return result + 10;
});
chain.use(async (_ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ base: 5 });
expect(result).toBe(20);
});
it('should allow middleware to short-circuit in the middle', async () => {
const executed: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
executed.push(1);
return next();
});
chain.use(async () => {
executed.push(2);
return { value: -1 };
});
chain.use(async (_ctx, next) => {
executed.push(3);
return next();
});
const result = await chain.execute({ value: 100 });
expect(result.value).toBe(-1);
expect(executed).toEqual([1, 2]);
});
});
describe('nested next calls', () => {
it('should advance index on each next call, skipping remaining middlewares', async () => {
const chain = createMiddlewareChain<{ counter: number }>();
chain.use(async (_ctx, next) => {
await next();
await next();
});
const result = await chain.execute({ counter: 0 });
expect(result).toBeUndefined();
});
it('should allow middleware to call next conditionally', async () => {
const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
chain.use(async (ctx, next) => {
if (ctx.skip) {
return { value: -1 };
}
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 10;
return next();
});
const resultA = await chain.execute({ skip: true, value: 0 });
const resultB = await chain.execute({ skip: false, value: 0 });
expect(resultA.value).toBe(-1);
expect(resultB.value).toBe(10);
});
it('should handle middleware that awaits next multiple times with a fallback', async () => {
const log: string[] = [];
const chain = createMiddlewareChain<{ value: number }, string[]>(
async _ctx => log
);
chain.use(async (_ctx, next) => {
log.push('before');
await next();
log.push('after-first');
await next();
log.push('after-second');
return log;
});
chain.use(async (_ctx, next) => {
log.push('mw2');
return next();
});
const result = await chain.execute({ value: 0 });
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe('async behavior', () => {
it('should handle async middlewares', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 2;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(3);
});
it('should handle async fallback', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => {
await new Promise(resolve => setTimeout(resolve, 10));
return ctx.value * 10;
}
);
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe('error handling', () => {
it('should propagate errors from middleware', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async () => {
throw new Error('middleware error');
});
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error');
});
it('should propagate errors from fallback', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async () => {
throw new Error('fallback error');
}
);
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
});
it('should allow middleware to catch errors from downstream', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
try {
return await next();
} catch {
return { value: -1 };
}
});
chain.use(async () => {
throw new Error('downstream error');
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(-1);
});
});
describe('return type override', () => {
it('should support different TReturn type than TContext', async () => {
const chain = createMiddlewareChain<{ name: string }, string>(
async ctx => `Hello, ${ctx.name}!`
);
const result = await chain.execute({ name: 'World' });
expect(result).toBe('Hello, World!');
});
it('should allow middleware to transform return type', async () => {
const chain = createMiddlewareChain<{ items: number[] }, number>(
async ctx => ctx.items.reduce((a, b) => a + b, 0)
);
chain.use(async (_ctx, next) => {
const sum = await next();
return sum * 2;
});
const result = await chain.execute({ items: [1, 2, 3] });
expect(result).toBe(12);
});
});
describe('reusability', () => {
it('should reset index on each execute call', async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count += 1;
return next();
});
const resultA = await chain.execute({ count: 0 });
const resultB = await chain.execute({ count: 0 });
expect(resultA.count).toBe(1);
expect(resultB.count).toBe(1);
});
it('should share middlewares across execute calls', async () => {
const chain = createMiddlewareChain<{ log: string[] }>();
chain.use(async (ctx, next) => {
ctx.log.push('always');
return next();
});
await chain.execute({ log: [] });
await chain.execute({ log: [] });
expect(chain).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle empty context object', async () => {
const chain = createMiddlewareChain<Record<string, never>>();
const result = await chain.execute({});
expect(result).toEqual({});
});
it('should handle middleware that returns a completely different object', async () => {
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
async () => ({ y: 'default' })
);
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ x: 42 });
expect(result).toEqual({ y: 'default' });
});
it('should handle middleware that mutates context without returning', async () => {
const chain = createMiddlewareChain<{ value: number }>(
async ctx => ctx
);
chain.use(async (ctx, next) => {
ctx.value = 100;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(100);
});
it('should return undefined when middleware does not call next or return', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx) => {
ctx.value = 100;
});
const result = await chain.execute({ value: 0 });
expect(result).toBeUndefined();
});
});
});