Skip to content

Commit 141beb2

Browse files
Add curse target roll mechanic with two-roll system
- First roll determines anchor: Previous, Oldest, Loudest, Quietest, Player's Choice, or Two Targets - Second roll determines offset: Track Before, That Track, or Track After - Player selects target for Loudest/Quietest/Player's Choice - Handles locked and deleted tracks - Shows target track in curse result view
1 parent e9c27b1 commit 141beb2

File tree

6 files changed

+290
-20
lines changed

6 files changed

+290
-20
lines changed

src/game/data.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ export const CURSE_TARGETS_FIRST: RangeTable = [
126126
[96, 100, 'Roll again for two Targets'],
127127
];
128128

129+
export const CURSE_TARGETS_SECOND: RangeTable = [
130+
[1, 32, 'Track Before'],
131+
[33, 66, 'That Track'],
132+
[67, 100, 'Track After'],
133+
];
134+
129135
export const RUN_TAGS: [string, string][] = [
130136
['Tragic', 'The Run collapsed in the last Room.'],
131137
['Cursed', 'Survived 3 or more Curses.'],

src/game/logic.ts

Lines changed: 205 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { RangeTable, Curse, Mutation, MutationEntry, TargetCurseEntry, MixCurseEntry } from './types';
2-
import { TRACK_TYPES, MUTATIONS, TARGET_CURSES, MIX_CURSES } from './data';
1+
import type { RangeTable, Curse, Mutation, MutationEntry, TargetCurseEntry, MixCurseEntry, CurseTargetMethod } from './types';
2+
import { TRACK_TYPES, MUTATIONS, TARGET_CURSES, MIX_CURSES, CURSE_TARGETS_FIRST, CURSE_TARGETS_SECOND } from './data';
33
import { getState, updateState, addLogEntry, createTrack } from './state';
44

55
export function roll(max = 100): number {
@@ -123,6 +123,86 @@ export function rollCurseCheck(): void {
123123
}
124124
}
125125

126+
function getAvailableTrackIndices(): number[] {
127+
const state = getState();
128+
if (!state) return [];
129+
130+
return state.tracks
131+
.map((track, idx) => ({ track, idx }))
132+
.filter(({ track, idx }) => !track.deleted && idx !== state.roomLockTrack)
133+
.map(({ idx }) => idx);
134+
}
135+
136+
function getCurseTargetMethod(rollValue: number): CurseTargetMethod {
137+
const result = getFromTable(rollValue, CURSE_TARGETS_FIRST);
138+
switch (result) {
139+
case 'Previous Track': return 'previous';
140+
case 'Oldest Track': return 'oldest';
141+
case 'Loudest Track': return 'loudest';
142+
case 'Quietest Track': return 'quietest';
143+
case "Player's Choice": return 'player-choice';
144+
case 'Roll again for two Targets': return 'two-targets';
145+
default: return 'previous';
146+
}
147+
}
148+
149+
function getSecondRollOffset(rollValue: number): -1 | 0 | 1 {
150+
const result = getFromTable(rollValue, CURSE_TARGETS_SECOND);
151+
switch (result) {
152+
case 'Track Before': return -1;
153+
case 'That Track': return 0;
154+
case 'Track After': return 1;
155+
default: return 0;
156+
}
157+
}
158+
159+
function applyOffsetToTarget(anchorIndex: number, offset: -1 | 0 | 1): number {
160+
const state = getState();
161+
if (!state) return anchorIndex;
162+
163+
const available = getAvailableTrackIndices();
164+
if (available.length === 0) return anchorIndex;
165+
166+
const targetIndex = anchorIndex + offset;
167+
168+
if (targetIndex < 0 || targetIndex >= state.tracks.length) {
169+
return anchorIndex;
170+
}
171+
172+
if (state.tracks[targetIndex].deleted || targetIndex === state.roomLockTrack) {
173+
return anchorIndex;
174+
}
175+
176+
return targetIndex;
177+
}
178+
179+
function resolveAutomaticTarget(method: CurseTargetMethod): number | null {
180+
const state = getState();
181+
if (!state) return null;
182+
183+
const available = getAvailableTrackIndices();
184+
if (available.length === 0) return null;
185+
186+
let anchorIndex: number;
187+
switch (method) {
188+
case 'previous':
189+
anchorIndex = available[available.length - 1];
190+
break;
191+
case 'oldest':
192+
anchorIndex = available[0];
193+
break;
194+
default:
195+
return null;
196+
}
197+
198+
const secondRoll = roll();
199+
const offset = getSecondRollOffset(secondRoll);
200+
const offsetLabel = offset === -1 ? 'Track Before' : offset === 1 ? 'Track After' : 'That Track';
201+
addLogEntry(`Second Roll: ${secondRoll}${offsetLabel}`);
202+
203+
return applyOffsetToTarget(anchorIndex, offset);
204+
}
205+
126206
function rollTargetCurse(): void {
127207
const state = getState();
128208
if (!state) return;
@@ -132,7 +212,111 @@ function rollTargetCurse(): void {
132212

133213
const curse: Curse = { type: 'Target Curse', roll: curseRoll, effect: entry.text };
134214
addLogEntry(`Target Curse Roll: ${curseRoll}${entry.text}`);
135-
updateState({ currentCurse: curse, phase: 'curse-result' });
215+
216+
const available = getAvailableTrackIndices();
217+
218+
if (available.length === 0) {
219+
addLogEntry('No available tracks to curse');
220+
updateState({ currentCurse: curse, phase: 'curse-result', pendingCurseTargets: [] });
221+
return;
222+
}
223+
224+
if (state.curseTargetTrackIndex !== null) {
225+
const targetIdx = state.curseTargetTrackIndex;
226+
if (!state.tracks[targetIdx]?.deleted && targetIdx !== state.roomLockTrack) {
227+
addLogEntry(`Curse Target: Track ${targetIdx + 1} (permanent target)`);
228+
updateState({ currentCurse: curse, phase: 'curse-result', pendingCurseTargets: [targetIdx] });
229+
return;
230+
}
231+
}
232+
233+
const targetRoll = roll();
234+
const method = getCurseTargetMethod(targetRoll);
235+
addLogEntry(`Curse Target Roll: ${targetRoll}${method}`);
236+
237+
if (method === 'two-targets') {
238+
addLogEntry('Rolling for two targets');
239+
const targets: number[] = [];
240+
241+
for (let i = 0; i < 2; i++) {
242+
const subRoll = roll();
243+
const subMethod = getCurseTargetMethod(subRoll);
244+
addLogEntry(`Target ${i + 1} Roll: ${subRoll}${subMethod}`);
245+
246+
if (subMethod === 'two-targets') {
247+
addLogEntry('Nested two-targets, defaulting to previous');
248+
const fallbackTarget = resolveAutomaticTarget('previous');
249+
if (fallbackTarget !== null) {
250+
targets.push(fallbackTarget);
251+
}
252+
} else if (subMethod === 'loudest' || subMethod === 'quietest' || subMethod === 'player-choice') {
253+
updateState({
254+
currentCurse: curse,
255+
phase: 'curse-target-select',
256+
curseTargetMethod: subMethod,
257+
curseTargetRoll: targetRoll,
258+
pendingCurseTargets: targets,
259+
});
260+
return;
261+
} else {
262+
const autoTarget = resolveAutomaticTarget(subMethod);
263+
if (autoTarget !== null) {
264+
targets.push(autoTarget);
265+
addLogEntry(`Target ${i + 1}: Track ${autoTarget + 1}`);
266+
}
267+
}
268+
}
269+
270+
updateState({ currentCurse: curse, phase: 'curse-result', pendingCurseTargets: [...new Set(targets)] });
271+
return;
272+
}
273+
274+
if (method === 'loudest' || method === 'quietest' || method === 'player-choice') {
275+
updateState({
276+
currentCurse: curse,
277+
phase: 'curse-target-select',
278+
curseTargetMethod: method,
279+
curseTargetRoll: targetRoll,
280+
pendingCurseTargets: [],
281+
});
282+
return;
283+
}
284+
285+
const autoTarget = resolveAutomaticTarget(method);
286+
if (autoTarget !== null) {
287+
addLogEntry(`Curse Target: Track ${autoTarget + 1}`);
288+
}
289+
290+
updateState({
291+
currentCurse: curse,
292+
phase: 'curse-result',
293+
pendingCurseTargets: autoTarget !== null ? [autoTarget] : [],
294+
});
295+
}
296+
297+
export function selectCurseTarget(trackIndex: number): void {
298+
const state = getState();
299+
if (!state?.currentCurse) return;
300+
301+
let finalTarget = trackIndex;
302+
303+
if (state.curseTargetMethod === 'loudest' || state.curseTargetMethod === 'quietest') {
304+
const secondRoll = roll();
305+
const offset = getSecondRollOffset(secondRoll);
306+
const offsetLabel = offset === -1 ? 'Track Before' : offset === 1 ? 'Track After' : 'That Track';
307+
addLogEntry(`Second Roll: ${secondRoll}${offsetLabel}`);
308+
finalTarget = applyOffsetToTarget(trackIndex, offset);
309+
}
310+
311+
addLogEntry(`Curse Target: Track ${finalTarget + 1}`);
312+
const targets = [...state.pendingCurseTargets, finalTarget];
313+
314+
updateState({
315+
phase: 'curse-result',
316+
pendingCurseTargets: [...new Set(targets)],
317+
curseTargetMethod: null,
318+
curseTargetRoll: null,
319+
});
136320
}
137321

138322
function rollMixCurse(): void {
@@ -176,18 +360,20 @@ export function acceptCurse(): void {
176360
if (state.currentCurse.type === 'Target Curse') {
177361
const targetEntry = curseEntry as TargetCurseEntry;
178362

179-
let targetIdx = curseTargetTrackIndex !== null
180-
? curseTargetTrackIndex
181-
: (tracks.length > 0 ? tracks.length - 1 : -1);
182-
183-
if (state.roomLockTrack !== null && targetIdx === state.roomLockTrack) {
184-
addLogEntry('Room Lock prevented curse on protected track');
185-
} else if (targetIdx >= 0) {
186-
tracks[targetIdx] = {
187-
...tracks[targetIdx],
188-
curses: [...tracks[targetIdx].curses, state.currentCurse.effect],
189-
deleted: targetEntry.mechanics?.deleteTrack ? true : tracks[targetIdx].deleted,
190-
};
363+
for (const targetIdx of state.pendingCurseTargets) {
364+
if (targetIdx >= 0 && targetIdx < tracks.length) {
365+
tracks[targetIdx] = {
366+
...tracks[targetIdx],
367+
curses: [...tracks[targetIdx].curses, state.currentCurse.effect],
368+
deleted: targetEntry.mechanics?.deleteTrack ? true : tracks[targetIdx].deleted,
369+
};
370+
addLogEntry(`Curse applied to Track ${targetIdx + 1}`);
371+
372+
if (targetEntry.mechanics?.becomesCurseTarget) {
373+
curseTargetTrackIndex = targetIdx;
374+
addLogEntry(`Track ${targetIdx + 1} is now the target of all future curses`);
375+
}
376+
}
191377
}
192378

193379
if (currentTrack) {
@@ -213,11 +399,6 @@ export function acceptCurse(): void {
213399
doubleMutationNextRoom = true;
214400
addLogEntry('Next room will have two mutations');
215401
}
216-
217-
if (targetEntry.mechanics?.becomesCurseTarget && targetIdx >= 0) {
218-
curseTargetTrackIndex = targetIdx;
219-
addLogEntry(`Track ${targetIdx + 1} is now the target of all future curses`);
220-
}
221402
} else {
222403
const mixEntry = curseEntry as MixCurseEntry;
223404
if (mixEntry.mechanics?.rollTargetCurses) {
@@ -233,6 +414,7 @@ export function acceptCurse(): void {
233414
doubleMutationNextRoom,
234415
curseTargetTrackIndex,
235416
currentCurse: null,
417+
pendingCurseTargets: [],
236418
phase: 'mutation',
237419
});
238420
}
@@ -433,6 +615,9 @@ export function nextRoom(): void {
433615
currentCurse: null,
434616
timerEndTime: null,
435617
pendingTrackTypeReselect: false,
618+
pendingCurseTargets: [],
619+
curseTargetMethod: null,
620+
curseTargetRoll: null,
436621
});
437622
}
438623

src/game/state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export function createInitialState(mode: GameMode, manualTrackType: boolean): Ga
5454
curseTargetTrackIndex: null,
5555
timerEndTime: null,
5656
pendingTrackTypeReselect: false,
57+
pendingCurseTargets: [],
58+
curseTargetMethod: null,
59+
curseTargetRoll: null,
5760
};
5861
}
5962

src/game/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@ export type Phase =
44
| 'track-type'
55
| 'track-type-reselect'
66
| 'curse-check'
7+
| 'curse-target-select'
78
| 'curse-result'
89
| 'mutation'
910
| 'mutation-result'
1011
| 'compose'
1112
| 'powerup-roll'
1213
| 'next-room';
1314

15+
export type CurseTargetMethod =
16+
| 'previous'
17+
| 'oldest'
18+
| 'loudest'
19+
| 'quietest'
20+
| 'player-choice'
21+
| 'two-targets';
22+
1423
export interface Track {
1524
room: number;
1625
type: string;
@@ -67,6 +76,9 @@ export interface GameState {
6776
curseTargetTrackIndex: number | null;
6877
timerEndTime: number | null;
6978
pendingTrackTypeReselect: boolean;
79+
pendingCurseTargets: number[];
80+
curseTargetMethod: CurseTargetMethod | null;
81+
curseTargetRoll: number | null;
7082
}
7183

7284
export type PowerUpType = 'redirect' | 'lock' | 'painshift' | 'split' | 'breath';

src/styles.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,25 @@ select {
208208
gap: 0.25rem;
209209
}
210210

211+
.track-select-list {
212+
display: flex;
213+
flex-direction: column;
214+
gap: 0.5rem;
215+
margin-top: 0.5rem;
216+
}
217+
218+
.track-select-btn {
219+
background: var(--surface2);
220+
border: 1px solid var(--border);
221+
text-align: left;
222+
padding: 0.75rem;
223+
}
224+
225+
.track-select-btn:hover {
226+
border-color: var(--accent);
227+
background: var(--surface);
228+
}
229+
211230
.roll-result {
212231
text-align: center;
213232
padding: 1rem;

0 commit comments

Comments
 (0)