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
+2 -2
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];
-102
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
-2
View File
@@ -1,2 +0,0 @@
# 《背包爬塔》肉鸽
@@ -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;
}
@@ -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";
@@ -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;
}
@@ -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",
"结束回合"
),
};
@@ -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 };
@@ -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);
}
}
@@ -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>;
@@ -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],
@@ -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;
@@ -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]
@@ -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;
@@ -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
1 # instant: 不施加buff,瞬间生效
2 # temporary: 施加buff,下回合开始时失效
3 # temporary: 施加buff,下回合开始时失效 # lingering: 施加buff,下回合开始时失去1层
4 # lingering: 施加buff,下回合开始时失去1层 # permanent: 施加buff
5 # permanent: 施加buff # posture: 施加buff,每受到1点伤害移除1层
6 # posture: 施加buff,每受到1点伤害移除1层 # item: 施加buff到周围物品,永久生效
# card: 不施加buff,对玩家时在玩家弃牌堆创建同名卡牌,对敌人无效(敌人没有牌堆)
# cardDraw: 不施加buff,在抓牌堆洗入同名卡牌
# cardHand:不施加buff,在玩家手牌中创建同名卡牌
# item: 施加buff到周围物品,永久生效
# itemUntilPlayed: 施加buff到周围物品,物品被打出后失效
7 id # itemTemporary: 施加buff到周围物品,持续到下次buff更新
8 string # itemUntilPlay: 施加buff到周围物品,物品被打出后失效
9 attack # itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
10 # itemPermanent: 施加buff到周围物品,持续整场冒险
11 id
12 string
13 attack
14 defend
15 spike
16 defend venom
17 spike curse
18 venom aim
19 curse roll
20 aim rollDamage
21 roll vultureEye
22 rollDamage tailSting
23 vultureEye energyDrain
24 tailSting molt
25 energyDrain discard
26 molt storm
27 discard static
37 damageReduce attackBuff
38 removeWound defendBuff
39 attackBuff gainEnergy
40 defendBuff energyNext
41 gainEnergy drawNext
42 energyNext defendBuffUntilPlay
43 drawNext drawChoice
44 defendBuffUntilPlay burnForEnergy
45 drawChoice attackBuffUntilPlay
46 burnForEnergy transformRandom
47 attackBuffUntilPlay expose
48
49
50
@@ -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;
@@ -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
@@ -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;
@@ -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次。
@@ -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;
@@ -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();
@@ -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]
@@ -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;
@@ -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 从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
@@ -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;
@@ -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();
});
}
@@ -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);
}
@@ -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;
@@ -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
@@ -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;
@@ -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;
@@ -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
@@ -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;
+4 -15
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
}
@@ -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:加入手牌。
@@ -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,
};
@@ -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;
}
@@ -1 +0,0 @@
export {default as encounters} from './encounters/encounters.yarnproject';
@@ -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提供的特性来设计环境内容:
- 效应:效应在技术上不依赖其他类型内容,可以首先确定。但在创意上可能不会直接设计效应,而是从若干创意中提取共性,并创建一个效应来作为主题。
- 卡牌:部分效应会创建卡牌或修改卡牌。在设计时需要注意。
- 敌人和意图:结合效应与主题表达设计敌人。通常需要为每个敌人设计独特的效应。
- 玩家物品:结合效应与主题表达设计物品。常见物品的效应容易找到替代,不常见物品则有特殊的效应。
-147
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';
@@ -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;
}
}
}
@@ -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
};
});
}
@@ -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;
},
}
}
@@ -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>;
@@ -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,
};
@@ -1,8 +1,7 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createCard,
createPlayerDeck,
generateCardId,
} from './factory';
@@ -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;
}
@@ -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>> {
@@ -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 */
@@ -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
@@ -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[]>;
}
@@ -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 {
@@ -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 */
@@ -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
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);
},
};
}
@@ -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();
});
});
});
@@ -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 {}
});
});
});
@@ -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('攻击');
});
});
});
@@ -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();
});
});
});
@@ -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();
});
});
@@ -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([]);
});
});
});
@@ -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');
}
});
});
@@ -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.
@@ -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 },
@@ -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', () => {
+436
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();
});
});
});