Compare commits

..

3 Commits

Author SHA1 Message Date
hypercross 52b6cecd64 refactor(slay-the-spire-like): update GameItemMeta import path and
reformat tests

Update imports for `GameItemMeta` from `progress/types` to
`encounter/types`
in combat and deck tests. Reformat `deck/factory.test.ts` to use double
quotes and consistent indentation.
2026-04-20 13:01:40 +08:00
hypercross f336a989e2 refactor(slay-the-spire-like): restructure encounter and progress
systems

Refactor the sample game logic by moving encounter-related logic from
the monolithic `progress` system into a dedicated `encounter` module.
This includes:

- Moving combat state construction and run state management to
  `system/encounter`.
- Decoupling player stats and inventory from the general progress
  logic.
- Improving type safety for `EncounterData` using generics.
- Adding navigation helpers to the map system.
- Cleaning up imports and improving code formatting.
2026-04-20 13:00:30 +08:00
hypercross 423cc7c841 feat(slay-the-spire-like): add map navigation logic
Implement `canMoveTo` and `moveToNode` for navigating the point
crawl map, and add `PointCrawlMapNavigator` type. Also reformat
map module files to use double quotes.
2026-04-20 11:59:52 +08:00
20 changed files with 665 additions and 1028 deletions

View File

@ -38,6 +38,8 @@ export type {
InventoryItem,
MutationResult,
PlacementResult,
GameItem,
GameItemMeta,
} from "./system/grid-inventory";
export {
createGridInventory,
@ -69,43 +71,8 @@ export {
} from "./system/map";
// Progress / Run
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from "./system/progress";
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
createRunState,
canMoveTo,
moveToNode,
resolveEncounter,
isEncounterResolved,
damagePlayer,
healPlayer,
setMaxHp,
addGold,
spendGold,
addItem,
removeItem,
getCurrentNode,
getReachableChildren,
getUnresolvedChildren,
isAtStartNode,
isAtEndNode,
} from "./system/progress";
export type { EncounterState, RunState } from "./system/encounter";
export { buildCombatState } from "./system/encounter";
// Combat
export type {

View File

@ -16,8 +16,6 @@ import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/pr
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: {};

View File

@ -1,34 +1,39 @@
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';
import { moveToRegion } from "@/core/region";
import { createRegion } from "@/core/region";
import type { GameItemMeta, GridInventory } from "../grid-inventory/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 {
function createCard(
itemId: string,
cardData: CardData,
cellIndex: number,
): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: '',
regionId: "",
position: [],
itemId,
cardData
cardData,
};
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
drawPile: createRegion("drawPile", []),
hand: createRegion("hand", []),
discardPile: createRegion("discardPile", []),
exhaustPile: createRegion("exhaustPile", []),
};
}
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
function generateDeckFromInventory(
inventory: GridInventory<GameItemMeta>,
): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
@ -46,7 +51,7 @@ function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): Play
return {
cards,
regions
regions,
};
}

View File

@ -0,0 +1,107 @@
import {
CombatState,
EffectTable,
EnemyEntity,
PlayerEntity,
} from "../combat/types";
import { generateDeckFromInventory } from "../deck";
import { GridInventory } from "../grid-inventory";
import { GameItemMeta } from "../grid-inventory/types";
import { EffectData, EncounterData, EnemyData, IntentData } from "../types";
import { CombatEncounterState, RunState } from "./types";
export function buildCombatState(
runState: RunState,
inventory: GridInventory<GameItemMeta>,
encounter: CombatEncounterState,
): CombatState {
const deck = generateDeckFromInventory(inventory);
const player = createPlayerEntity(runState, deck);
const enemies = createEnemyEntities(encounter.data);
return {
enemies,
player,
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = {
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects,
intents,
currentIntent: initialIntent,
};
enemies.push(entity);
}
return enemies;
}
/**
* Builds a map of intent ID -> IntentData for an enemy.
*/
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
const intents: Record<string, IntentData> = {};
for (const intent of enemy.intents) {
intents[intent.id] = intent;
}
return intents;
}
/**
* Finds the initial intent ID for an enemy.
*/
function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) {
if (intent.initialIntent) {
return intent;
}
}
if (enemy.intents.length === 0) {
throw new Error(`Enemy "${enemy.id}" has no intents`);
}
return enemy.intents[0];
}
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
function createPlayerEntity(
runState: RunState,
deck: ReturnType<typeof generateDeckFromInventory>,
): PlayerEntity {
return {
id: "player",
hp: runState.currentHp,
maxHp: runState.maxHp,
isAlive: runState.currentHp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: {},
effects: {},
};
}

View File

@ -0,0 +1,3 @@
export { RunState, EncounterState } from "./types";
export { buildCombatState } from "./combat";
export { generateInstanceId } from "./shop";

View File

@ -0,0 +1,52 @@
import { RunState } from "./types";
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
export function createRunState(startNode: string): RunState {
return {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
_idCounter: { value: 0 },
};
}
export function damagePlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.max(0, runState.currentHp - amount);
}
export function healPlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount);
}
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = Math.max(0, newMax - runState.maxHp);
runState.maxHp = newMax;
runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(
runState: RunState,
amount: number,
): { success: true } | { success: false; reason: string } {
if (amount <= 0) {
return { success: false, reason: "金额必须大于零" };
}
if (runState.gold < amount) {
return { success: false, reason: "金币不足" };
}
runState.gold -= amount;
return { success: true };
}

View File

@ -0,0 +1,4 @@
export function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}

View File

@ -0,0 +1,42 @@
import { GridInventory } from "../grid-inventory";
import { GameItemMeta } from "../grid-inventory/types";
import { EncounterData } from "../types";
export interface RunState {
_idCounter: { value: number };
currentHp: number;
maxHp: number;
gold: number;
}
export type EncounterState =
| CombatEncounterState
| ShopEncounterState
| CurioEncounterState
| CampEncounterState
| DialogueEncounterState;
export type CombatEncounterState = {
data: EncounterData<"minion" | "elite">;
blocked: boolean;
};
export type ShopEncounterState = {
data: EncounterData<"shop">;
items: GridInventory<GameItemMeta & { sellPrice: number }>;
};
export type CurioEncounterState = {
data: EncounterData<"curio">;
items: GridInventory<GameItemMeta>;
};
export type CampEncounterState = {
data: EncounterData<"camp">;
};
export type DialogueEncounterState = {
data: EncounterData<"event">;
blocked: boolean;
};

View File

@ -1,4 +1,11 @@
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
export type {
CellCoordinate,
CellKey,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
export {
createGridInventory,
flipItem,
@ -10,4 +17,6 @@ export {
removeItem,
rotateItem,
validatePlacement,
} from './transform';
} from "./transform";
export type { GameItemMeta, GameItem } from "./types";

View File

@ -1,5 +1,6 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import { ItemData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
/**
* String key representing a grid cell in "x,y" format.
@ -32,12 +33,16 @@ export interface InventoryItem<TMeta> {
/**
* Result of a placement validation check.
*/
export type PlacementResult = { valid: true } | { valid: false; reason: string };
export type PlacementResult =
| { valid: true }
| { valid: false; reason: string };
/**
* Result of a mutation operation (move, rotate, flip).
*/
export type MutationResult = { success: true } | { success: false; reason: string };
export type MutationResult =
| { success: true }
| { success: false; reason: string };
/**
* Grid inventory state.
@ -54,3 +59,12 @@ export interface GridInventory<TMeta> {
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
export interface GameItemMeta {
itemData: ItemData;
shape: ParsedShape;
consumedUses?: number;
startEffects?: Record<string, number>;
tradePrice?: number;
}
export type GameItem = InventoryItem<GameItemMeta>;

View File

@ -1,5 +1,24 @@
export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
export { MapNodeType, MapLayerType } from "./types";
export type {
MapNode,
MapLayer,
PointCrawlMap,
MapGenerationConfig,
} from "./types";
export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
export { generatePointCrawlMap } from "./generator";
export {
getNode,
getChildren,
getParents,
hasPath,
findAllPaths,
} from "./generator";
export {
canMoveTo,
moveToNode,
getReachableChildren,
isAtEndNode,
isAtStartNode,
} from "./navigation";

View File

@ -0,0 +1,65 @@
import { getNode } from "./generator";
import { PointCrawlMap, PointCrawlMapNavigator } from "./types";
export function canMoveTo(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
export function moveToNode(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
if (!canMoveTo(navigator, map, targetNodeId)) {
return false;
}
const targetNode = getNode(map, targetNodeId);
if (!targetNode) {
return false;
}
// Update current position
navigator.currentNodeId = targetNodeId;
navigator.visitedNodes.add(targetNodeId);
return true;
}
export function* getReachableChildren(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
) {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return;
for (const id of currentNode.childIds) {
const node = getNode(map, id);
if (!node) continue;
yield node;
}
}
export function isAtStartNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
const endLayer = map.layers[map.layers.length - 1];
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
}

View File

@ -4,22 +4,22 @@ import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
* Types of nodes that can appear on the point crawl map.
*/
export enum MapNodeType {
Start = 'start',
End = 'end',
Minion = 'minion',
Elite = 'elite',
Event = 'event',
Camp = 'camp',
Shop = 'shop',
Curio = 'curio',
Start = "start",
End = "end",
Minion = "minion",
Elite = "elite",
Event = "event",
Camp = "camp",
Shop = "shop",
Curio = "curio",
}
/**
* Semantic type of a layer.
*/
export enum MapLayerType {
Wild = 'wild',
Settlement = 'settlement',
Wild = "wild",
Settlement = "settlement",
}
/**
@ -47,7 +47,7 @@ export interface MapLayer {
/** Ordered IDs of nodes in this layer */
nodeIds: string[];
/** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end';
layerType: MapLayerType | "start" | "end";
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
}
@ -64,6 +64,11 @@ export interface PointCrawlMap {
parentIndex?: Map<string, string[]>;
}
export interface PointCrawlMapNavigator {
currentNodeId: string;
visitedNodes: Set<string>;
}
/**
* Configuration for map generation.
*/

View File

@ -1,256 +0,0 @@
import type { PointCrawlMap } from "../map/types";
import type {
CombatState,
EnemyEntity,
PlayerEntity,
EffectTable,
} from "../combat/types";
import type {
EncounterData,
EnemyData,
EffectData,
IntentData,
} from "../types";
import type { RunState } from "./types";
import { generateDeckFromInventory } from "../deck/factory";
import { ReadonlyRNG } from "@/utils/rng";
// -- Encounter assignment to nodes --
/**
* Assigns an encounter from a pool to a specific node.
* Replaces any existing encounter on that node.
*/
export function assignEncounterToNode(
map: PointCrawlMap,
nodeId: string,
encounter: EncounterData,
): void {
const node = map.nodes.get(nodeId);
if (!node) {
throw new Error(`Node "${nodeId}" not found`);
}
node.encounter = encounter;
}
/**
* Assigns encounters from a typed pool to all unassigned nodes of matching type.
* Uses RNG for random selection; each encounter can be assigned multiple times.
*/
export function assignEncountersFromPool(
map: PointCrawlMap,
encounterPool: EncounterData[],
rng: ReadonlyRNG,
): void {
if (encounterPool.length === 0) return;
for (const node of map.nodes.values()) {
if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue;
const assigned = encounterPool[rng.nextInt(encounterPool.length)];
node.encounter = assigned;
}
}
/**
* Batch-assigns encounters for all node types from a typed index.
* Keys in the index should match encounter type strings (e.g. 'minion', 'elite').
*/
export function assignAllEncounters(
map: PointCrawlMap,
encounterIndex: Map<string, EncounterData[]>,
rng: ReadonlyRNG,
): void {
for (const node of map.nodes.values()) {
if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue;
const encounterType = node.type;
const pool = encounterIndex.get(encounterType);
if (!pool || pool.length === 0) continue;
node.encounter = pool[rng.nextInt(pool.length)];
}
}
// -- CombatState construction --
/**
* Builds a full CombatState from an encounter and the current run state.
* - Creates EnemyEntity instances with HP, initial buffs, and intents
* - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state
* - Sets initial phase to 'playerTurn', turn 1
*/
export function buildCombatState(runState: RunState): CombatState {
const encounter = getCurrentEncounterData(runState);
if (!encounter)
throw new Error(`No encounter found for node ${runState.currentNodeId}`);
const enemies = createEnemyEntities(encounter);
const deck = generateDeckFromInventory(runState.inventory);
const player = createPlayerEntity(runState, deck);
return {
enemies,
player,
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
/**
* Creates EnemyEntity instances from encounter enemy definitions.
* Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition.
*/
export function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = {
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects,
intents,
currentIntent: initialIntent,
};
enemies.push(entity);
}
return enemies;
}
/**
* Builds a map of intent ID -> IntentData for an enemy.
*/
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
const intents: Record<string, IntentData> = {};
for (const intent of enemy.intents) {
intents[intent.id] = intent;
}
return intents;
}
/**
* Finds the initial intent ID for an enemy.
*/
function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) {
if (intent.initialIntent) {
return intent;
}
}
if (enemy.intents.length === 0) {
throw new Error(`Enemy "${enemy.id}" has no intents`);
}
return enemy.intents[0];
}
/**
* Builds an EffectTable from encounter buff definitions.
*/
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
/**
* Creates a PlayerEntity from the run state and deck.
*/
function createPlayerEntity(
runState: RunState,
deck: ReturnType<typeof generateDeckFromInventory>,
): PlayerEntity {
return {
id: "player",
hp: runState.player.currentHp,
maxHp: runState.player.maxHp,
isAlive: runState.player.currentHp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: {},
effects: {},
};
}
// -- Encounter lifecycle --
/**
* Gets the encounter data for the current node.
*/
export function getCurrentEncounterData(
runState: RunState,
): EncounterData | undefined {
const node = runState.map.nodes.get(runState.currentNodeId);
return node?.encounter;
}
/**
* Checks if the current node has a combat encounter.
*/
export function isCombatEncounter(runState: RunState): boolean {
const encounter = getCurrentEncounterData(runState);
return encounter !== undefined && encounter.enemies.length > 0;
}
/**
* Starts the encounter at the current node.
* Returns the constructed CombatState, or null if no combat encounter.
*/
export function startEncounter(runState: RunState): CombatState | null {
const encounter = getCurrentEncounterData(runState);
if (!encounter || encounter.enemies.length === 0) {
return null;
}
return buildCombatState(runState);
}
/**
* Resolves a completed combat and applies rewards to the run state.
* Handles: gold loot, item rewards, HP changes.
* Marks the encounter as resolved.
*/
export function resolveCombatEncounter(
runState: RunState,
combatState: CombatState,
): { success: true } | { success: false; reason: string } {
if (runState.currentEncounter.resolved) {
return { success: false, reason: "该遭遇已解决" };
}
// Apply HP from combat state back to run state
runState.player.currentHp = Math.max(0, combatState.player.hp);
// Apply loot
for (const loot of combatState.loot) {
if (loot.type === "gold") {
runState.player.gold += loot.amount;
}
// Item rewards are handled by the caller via addItem()
}
// Mark as resolved
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = {
hpLost: runState.player.maxHp - runState.player.currentHp,
};
runState.resolvedNodeIds.add(runState.currentNodeId);
return { success: true };
}

View File

@ -1,326 +0,0 @@
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 {ItemData} from "@/samples/slay-the-spire-like/system/types";
// Re-export types
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './types';
// Re-export encounter construction functions
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
} from './encounter';
// -- Constants --
const INVENTORY_WIDTH = 6;
const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
*/
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
// Find the start node
const startNode = map.layers[0].nodes[0];
// Create empty inventory
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
const idCounter = { value: 0 };
// Place starter items
for (const itemData of starterItems) {
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
// Inventory too small; item skipped
}
}
return {
map,
player: {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
},
inventory,
currentNodeId: startNode.id,
currentEncounter: {
nodeId: startNode.id,
resolved: false,
},
resolvedNodeIds: new Set<string>(),
_idCounter: idCounter,
};
}
// -- Movement --
/**
* Checks whether the player can move to the target node.
* The target must be a child of the current node.
*/
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
/**
* Moves the player to the target node.
* The target must be a direct child of the current node.
*
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
*/
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
if (!canMoveTo(runState, targetNodeId)) {
return { success: false, reason: '无法移动到该节点' };
}
const targetNode = getNode(runState.map, targetNodeId);
if (!targetNode) {
return { success: false, reason: '目标节点不存在' };
}
// Update current position
runState.currentNodeId = targetNodeId;
// Create new encounter state for the target node
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
runState.currentEncounter = {
nodeId: targetNodeId,
resolved: isResolved,
};
return { success: true };
}
// -- Encounter management --
/**
* Marks the current encounter as resolved with optional result data.
*/
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = result;
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply result effects
if (result) {
if (result.goldEarned) {
runState.player.gold += result.goldEarned;
}
if (result.hpLost) {
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
}
if (result.hpGained) {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
}
}
return { success: true };
}
/**
* Checks whether the encounter at the given node has been resolved.
*/
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
return runState.resolvedNodeIds.has(nodeId);
}
// -- Player stats --
/**
* Damages the player. Clamps HP to 0 minimum.
*/
export function damagePlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
}
/**
* Heals the player. Clamps HP to maxHp.
*/
export function healPlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
}
/**
* Sets the player's maximum HP and adjusts current HP proportionally.
*/
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = newMax - runState.player.maxHp;
runState.player.maxHp = newMax;
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.player.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(runState: RunState, amount: number): RunMutationResult {
if (amount <= 0) {
return { success: false, reason: '金额必须大于零' };
}
if (runState.player.gold < amount) {
return { success: false, reason: '金币不足' };
}
runState.player.gold -= amount;
return { success: true };
}
// -- Inventory management --
/**
* Adds an item from CSV data to the inventory.
* Finds the first available valid position.
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItem(
runState: RunState,
itemData: ItemData
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
}
/**
* Removes an item from the inventory by its instance ID.
*/
export function removeItem(runState: RunState, instanceId: string): void {
gridRemoveItem(runState.inventory, instanceId);
}
// -- Query helpers --
/**
* Returns the node the player is currently at.
*/
export function getCurrentNode(runState: RunState): MapNode | undefined {
return getNode(runState.map, runState.currentNodeId);
}
/**
* Returns all reachable child nodes from the current position.
*/
export function getReachableChildren(runState: RunState): MapNode[] {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return [];
return currentNode.childIds
.map(id => getNode(runState.map, id))
.filter((n): n is MapNode => n !== undefined);
}
/**
* Returns available children that haven't been resolved yet.
*/
export function getUnresolvedChildren(runState: RunState): MapNode[] {
return getReachableChildren(runState).filter(
node => !runState.resolvedNodeIds.has(node.id)
);
}
/**
* Checks if the current encounter is the start node (no encounter).
*/
export function isAtStartNode(runState: RunState): boolean {
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(runState: RunState): boolean {
const endLayer = runState.map.layers[runState.map.layers.length - 1];
return endLayer?.nodes[0]?.id === runState.currentNodeId;
}
// -- Internal helpers --
/**
* Generates a unique item instance ID.
*/
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
/**
* Tries to place an item in the inventory by scanning all possible positions.
* Returns the placed item instance, or undefined if no valid position exists.
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: ItemData,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {
const instanceId = generateInstanceId(idCounter);
// Try to find a valid position
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform = {
...IDENTITY_TRANSFORM,
offset: { x, y },
};
const validation = validatePlacement(inventory, shape, transform);
if (validation.valid) {
const item: GameItem = {
id: instanceId,
shape,
transform,
meta: {
itemData,
shape,
},
};
placeItem(inventory, item);
return item;
}
}
}
return undefined;
}

View File

@ -1,91 +0,0 @@
import type { PointCrawlMap } from "../map/types";
import type { GridInventory, InventoryItem } from "../grid-inventory/types";
import type { ParsedShape } from "../utils/parse-shape";
import { ItemData } from "@/samples/slay-the-spire-like/system/types";
/**
* Result of an encounter (combat, event, etc.).
*/
export interface EncounterResult {
/** Gold earned from the encounter */
goldEarned?: number;
/** HP lost during the encounter */
hpLost?: number;
/** HP gained (e.g., from camp heal) */
hpGained?: number;
/** Item IDs rewarded */
itemRewards?: string[];
}
/**
* Runtime state of an encounter at a specific node.
*/
export interface EncounterState {
/** The node ID where this encounter is located */
nodeId: string;
/** Whether the encounter has been resolved */
resolved: boolean;
/** Optional result data after resolution */
result?: EncounterResult;
}
/**
* Metadata attached to each inventory item instance.
* Bridges CSV item data with the grid inventory system.
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: ItemData;
/** Parsed shape for grid placement */
shape: ParsedShape;
/** Consumed uses, if card cost type is uses**/
consumedUses?: number;
/** Effects applied to the item */
effects?: Record<string, number>;
}
/**
* An item instance in the game inventory.
* Extends InventoryItem with game-specific metadata.
*/
export type GameItem = InventoryItem<GameItemMeta>;
/**
* Player runtime state.
*/
export interface PlayerState {
/** Maximum HP */
maxHp: number;
/** Current HP */
currentHp: number;
/** Current gold */
gold: number;
}
/**
* Full run state for a game session.
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */
player: PlayerState;
/** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */
currentNodeId: string;
/** State of the encounter at the current node */
currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */
_idCounter: { value: number };
}
/**
* Result of a mutation operation on the run state.
*/
export type RunMutationResult =
| { success: true }
| { success: false; reason: string };

View File

@ -56,9 +56,9 @@ export type EncounterType =
| "shop"
| "camp"
| "curio";
export type EncounterData = {
export type EncounterData<T extends EncounterType = EncounterType> = {
readonly id: string;
readonly type: EncounterType;
readonly type: T;
readonly name: string;
readonly description: string;
readonly enemies: readonly [

View File

@ -30,7 +30,7 @@ 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 { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/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";

View File

@ -26,7 +26,7 @@ import {
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import {

View File

@ -1,17 +1,26 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
generateDeckFromInventory,
createCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} 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';
} 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/encounter/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.
@ -21,10 +30,10 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
id,
name,
desc,
type: 'item',
costType: 'energy',
type: "item",
costType: "energy",
costCount: 1,
targetType: 'single',
targetType: "single",
effects: [],
};
}
@ -32,10 +41,15 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
/**
* Helper: create a minimal ItemData for testing.
*/
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData {
function createTestItemData(
id: string,
name: string,
shapeStr: string,
desc: string,
): ItemData {
return {
id,
type: 'weapon',
type: "weapon",
name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
@ -47,7 +61,11 @@ function createTestItemData(id: string, name: string, shapeStr: string, desc: st
/**
* Helper: create a minimal GameItemMeta for testing.
*/
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
function createTestMeta(
name: string,
desc: string,
shapeStr: string,
): GameItemMeta {
const shape = parseShapeString(shapeStr);
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
return {
@ -63,9 +81,9 @@ function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
// Item "短刀" with shape "oe" (2 cells)
const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe');
const meta1 = createTestMeta("短刀", "【攻击3】【攻击3】", "oe");
const item1: InventoryItem<GameItemMeta> = {
id: 'dagger-1',
id: "dagger-1",
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
@ -73,9 +91,9 @@ function createTestInventory(): GridInventory<GameItemMeta> {
placeItem(inv, item1);
// Item "盾" with shape "oesw" (4 cells)
const meta2 = createTestMeta('盾', '【防御3】', 'oesw');
const meta2 = createTestMeta("盾", "【防御3】", "oesw");
const item2: InventoryItem<GameItemMeta> = {
id: 'shield-1',
id: "shield-1",
shape: meta2.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
meta: meta2,
@ -85,30 +103,30 @@ function createTestInventory(): GridInventory<GameItemMeta> {
return inv;
}
describe('deck/factory', () => {
describe('generateCardId', () => {
it('should generate deterministic unique IDs', () => {
expect(generateCardId('item-1', 0)).toBe('card-item-1-0');
expect(generateCardId('item-1', 1)).toBe('card-item-1-1');
expect(generateCardId('item-2', 0)).toBe('card-item-2-0');
describe("deck/factory", () => {
describe("generateCardId", () => {
it("should generate deterministic unique IDs", () => {
expect(generateCardId("item-1", 0)).toBe("card-item-1-0");
expect(generateCardId("item-1", 1)).toBe("card-item-1-1");
expect(generateCardId("item-2", 0)).toBe("card-item-2-0");
});
});
describe('createCard', () => {
it('should create a card with itemId and cardData', () => {
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
const card = createCard('wound-1', cardData, 0);
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.id).toBe("card-wound-1-0");
expect(card.itemId).toBe("wound-1");
expect(card.cardData).toBe(cardData);
expect(card.regionId).toBe('');
expect(card.regionId).toBe("");
expect(card.position).toEqual([]);
});
});
describe('generateDeckFromInventory', () => {
it('should generate correct number of cards based on shape cell counts', () => {
describe("generateDeckFromInventory", () => {
it("should generate correct number of cards based on shape cell counts", () => {
const inv = createTestInventory();
// "短刀" has 2 cells, "盾" has 4 cells = 6 total
@ -121,26 +139,26 @@ describe('deck/factory', () => {
expect(deck.regions.exhaustPile.childIds).toEqual([]);
});
it('should link cards to their source items', () => {
it("should link cards to their source items", () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
const daggerCards = Object.values(deck.cards).filter(
c => c.itemId === 'dagger-1'
(c) => c.itemId === "dagger-1",
);
const shieldCards = Object.values(deck.cards).filter(
c => c.itemId === 'shield-1'
(c) => c.itemId === "shield-1",
);
expect(daggerCards.length).toBe(2);
expect(shieldCards.length).toBe(4);
// Verify card data
expect(daggerCards[0].cardData.name).toBe('短刀');
expect(shieldCards[0].cardData.name).toBe('盾');
expect(daggerCards[0].cardData.name).toBe("短刀");
expect(shieldCards[0].cardData.name).toBe("盾");
});
it('should set displayName and description from item data', () => {
it("should set displayName and description from item data", () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
@ -150,26 +168,26 @@ describe('deck/factory', () => {
}
const daggerCard = Object.values(deck.cards).find(
c => c.cardData.name === '短刀'
(c) => c.cardData.name === "短刀",
);
expect(daggerCard?.cardData.name).toBe('短刀');
expect(daggerCard?.cardData.desc).toBe('【攻击3】【攻击3】');
expect(daggerCard?.cardData.name).toBe("短刀");
expect(daggerCard?.cardData.desc).toBe("【攻击3】【攻击3】");
});
it('should assign unique IDs 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.itemId === 'dagger-1'
(c) => c.itemId === "dagger-1",
);
const ids = daggerCards.map(c => c.id);
const ids = daggerCards.map((c) => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should handle empty inventory', () => {
it("should handle empty inventory", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
const deck = generateDeckFromInventory(inv);
@ -177,7 +195,7 @@ describe('deck/factory', () => {
expect(deck.regions.drawPile.childIds).toEqual([]);
});
it('should place all cards in draw pile initially', () => {
it("should place all cards in draw pile initially", () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
@ -186,21 +204,23 @@ describe('deck/factory', () => {
}
// All cards are in draw pile
expect(new Set(deck.regions.drawPile.childIds).size).toBe(Object.keys(deck.cards).length);
expect(new Set(deck.regions.drawPile.childIds).size).toBe(
Object.keys(deck.cards).length,
);
});
});
describe('createDeckRegions', () => {
it('should create regions for all deck zones', () => {
describe("createDeckRegions", () => {
it("should create regions for all deck zones", () => {
const regions = createDeckRegions();
expect(regions.drawPile.id).toBe('drawPile');
expect(regions.hand.id).toBe('hand');
expect(regions.discardPile.id).toBe('discardPile');
expect(regions.exhaustPile.id).toBe('exhaustPile');
expect(regions.drawPile.id).toBe("drawPile");
expect(regions.hand.id).toBe("hand");
expect(regions.discardPile.id).toBe("discardPile");
expect(regions.exhaustPile.id).toBe("exhaustPile");
});
it('should have empty childIds initially', () => {
it("should have empty childIds initially", () => {
const regions = createDeckRegions();
expect(regions.drawPile.childIds).toEqual([]);
@ -210,8 +230,8 @@ describe('deck/factory', () => {
});
});
describe('createPlayerDeck', () => {
it('should create an empty deck structure', () => {
describe("createPlayerDeck", () => {
it("should create an empty deck structure", () => {
const deck = createPlayerDeck();
expect(deck.cards).toEqual({});