2026 10 23 project towers - xopherdeep/do-it-for-the-xp GitHub Wiki
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A Game Master starts a big family project (Spring Cleaning, Moving Out), the game builds a tower of one-off chores with one floor per phase and a roof final boss, the family climbs it together against an end date, early floors pay a shared bonus, and the finish unlocks a family reward, rolls the credits, and leaves a trophy.
Architecture: Project types are data files. Pure modules under src/lib/services/projects/ turn a type, the house answers and the edited chore list into a schedule, a generated temple record (gen-project-<typeId>, laid out per floor by the Old House planner) and one-off quests tagged with a project link. One family-level ProjectDb record holds the dates, floors, bonus state and reward. A thin projectService.ts commits plans: it is called from the quest-payment paths to settle floors (bonus, next floor wake, finish). Screens (wizard, temple header, Hometown, Approvals, credits) only read and call it.
Tech Stack: Vue 3 + Ionic 7 + TypeScript, Pinia, localForage (@ionic/storage), jest (ts-jest), Cypress 8.
Spec: docs/superpowers/specs/2026-10-23-project-towers-design.md (owner-approved 2026-10-23).
- Work on a branch
feat/project-towersin a worktree offorigin/master; symlink the main checkout'snode_modulesinto it. Builders never run git; the lead commits each task with explicit paths. - Tools:
chemx read/chemx qfor reading; run only the spec a task touches withchemx test tests/unit/<Name>.spec.ts; nochemx typecheck,chemx verify, lint or build until the lead's gate after the last task; never rawpnpm/npx/jest. - Game rules live in pure modules with jest specs; screens stay thin. New components use
<script setup lang="ts">. Name compound booleans (isX,hasX): the audit flags inline ones. - Rewards:
rewardsFor(d)frompacks/packCommon.ts(xp = d*100,gp = d*10,ap = d). Bands fromheroBandFor: little difficulty 1-2, kid 2-5, grown-up 5-13. A kid's chore of difficulty 5 or more requires approval (the Moving House pack's rule). - Due time of every project chore:
PROJECT_DUE_TIME = "20:00". - Floor bonus: ×1 at the due day rising linearly to ×2 for a floor cleared within the first half of its phase; rounded to one decimal; applies to XP and GP only (never AP); split evenly among the heroes who did at least one of the floor's chores; a remainder goes to the hero who finished the floor's last chore. The bonus changes rewards only, never fights (the game never grows harder).
- One active project per family. Abandoning keeps paid chores, deletes the project's open quests and the tower, leaves no trophy.
- In-world copy goes through the copy catalogue in three voices (
copyFor(key, voice, vars)); no em dashes in new copy; toasts usecssClass: "rpg-toast", positionmiddle; reveal prompts, not Ionic alerts, for in-world moments. - Loads merge, never wipe family data. New stores join
BACKUP_STORES(src/lib/services/core/BackupService.ts). - Credits privacy gate (spec section 7):
src/lib/services/copy/credits.tswith the makers' names and birth years is committed only after the owner confirms in chat that the names and ages may be public (the repository is public).
- Nobody walks upstairs after a floor clears. If the next floor only woke on arrival, its chores would never reach the board and its dates would pass unseen. Expected: the next floor wakes the moment the floor below clears (and on arrival, whichever comes first; waking twice changes nothing). Pinned in Task 8 ("wakes the next floor when a floor clears").
-
A kid's chore needs approval. The kid finishes on time, the Game Master approves two days later. Expected: the floor's clear day is the latest claim time (
completedAt) of its chores, not the approval day, so the kid keeps the bonus. Pinned in Task 8 ("dates the clear by the claim, not the approval"). - The Game Master deletes a project chore from the quest list mid-project. Expected: a deleted quest no longer counts, so the floor can still clear. Pinned in Task 8 ("ignores a deleted chore").
- The same floor settles twice (a second approval, two phones). Expected: the bonus is paid once and the next floor wakes once. Pinned in Task 8 ("settles a floor once").
-
An end date too close or in the past. Expected: the wizard refuses an end date before
minimumEndOn(one day per phase) and extending refuses a date before today. Pinned in Task 3 and Task 9.
Files:
- Modify:
src/lib/databases/AchievementDb.ts(theAchievementinterface, L17-83) - Modify:
src/lib/engine/quests/QuestSchedule.ts(deadlineDayOfL229,isQuestActiveOnL282) - Test:
tests/unit/QuestScheduleProjectQuests.spec.ts
Interfaces:
-
Produces:
interface ProjectQuestLink { projectId: string; floor: string; dueOn: string /* YYYY-MM-DD */; wokenAt?: string /* ISO */ }exported fromAchievementDb.ts;Achievement.project?: ProjectQuestLink;isProjectQuest(quest): quest is Achievement & { project: ProjectQuestLink }exported fromQuestSchedule.ts. -
Step 1: Write the failing tests
describe("project quests", () => {
const base = { ...AchievementDb.getDefault(), id: "pq-x", basicSchedule: "once", startsOn: "2027-04-03", dueByTime: "20:00" };
it("hides a project quest until its floor wakes", () => {
expect(isQuestActiveOn({ ...base, project: { projectId: "p", floor: "2F", dueOn: "2027-04-09" } }, new Date(2027, 3, 5))).toBe(false);
});
it("shows a woken project quest before its startsOn", () => {
const woken = { ...base, project: { projectId: "p", floor: "2F", dueOn: "2027-04-09", wokenAt: "2027-04-01T10:00:00Z" } };
expect(isQuestActiveOn(woken, new Date(2027, 3, 1))).toBe(true);
});
it("is due on project.dueOn at its due time", () => {
const woken = { ...base, project: { projectId: "p", floor: "1F", dueOn: "2027-04-09", wokenAt: "2027-04-03T08:00:00Z" } };
expect(effectiveDeadline(woken, undefined, new Date(2027, 3, 5))).toEqual(new Date(2027, 3, 9, 20, 0));
});
it("leaves a once quest without a project link as it was", () => { /* deadline day = startsOn, hidden before startsOn */ });
});- Step 2: Run
chemx test tests/unit/QuestScheduleProjectQuests.spec.ts. Expected: FAIL (projectunknown / assertions fail). - Step 3: Implement. Add
ProjectQuestLinkand the optional field. InQuestSchedule.tsaddisProjectQuest;deadlineDayOfreturnsparseLocalDate(quest.project.dueOn)for a project quest (before the snooze/extension rules it already applies);isQuestActiveOnreturnsBoolean(quest.project.wokenAt)for a project quest, otherwise its current rule. Search every caller ofisQuestActiveOn/deadlineDayOf(chemx q -g "isQuestActiveOn") and confirm none hides a once quest beforestartsOnby another path; if one does, route it throughisQuestActiveOn. - Step 4: Run the spec and
tests/unit/QuestSchedule.spec.ts. Expected: PASS. - Step 5: Lead commits
feat(quests): project quests: hidden until their floor wakes, due on project.dueOn.
Files:
- Create:
src/lib/services/core/packs/projects/projectTypes.ts,springCleaning.ts,movingOut.ts,index.ts - Test:
tests/unit/projectTypes.spec.ts
Interfaces:
-
Consumes:
MovingRoomId,MOVING_TEMPLATES,MOVING_PHASES(packs/movingPack.ts);PackBand(packCommon.ts). -
Produces:
type ProjectTypeId = "spring-cleaning" | "moving-out"interface ProjectChoreSuggestion { slug: string; name: string; rooms: readonly MovingRoomId[] | "house"; difficulty: number; who: PackBand | "any" | "team"; beastId?: string; minutes?: number }interface ProjectPhase { id: string; name: string; share: number; chores: readonly ProjectChoreSuggestion[] }interface ProjectType { id: ProjectTypeId; name: string; towerName: string; element: string; defaultDays: number; phases: readonly ProjectPhase[]; finale: ProjectChoreSuggestion }-
PROJECT_TYPES: Readonly<Record<ProjectTypeId, ProjectType>>,projectTypeOf(id: string): ProjectType | undefined
-
Step 1: Write the failing tests (
it.each(Object.values(PROJECT_TYPES))): shares sum to 1 (to 1e-9); every phase has at least one chore; slugs are unique within a type; every difficulty is on the Fibonacci slider (1, 2, 3, 5, 8, 13);finale.difficulty >= 8;springCleaninghas phases["declutter", "deep-clean", "organize", "final-inspection"]with shares[0.3, 0.4, 0.2, 0.1],defaultDays14,towerName"The Spring Spire", finale name "The Final Inspection";movingOuthas phases["declutter", "quiet", "living", "last"](the Moving House pack's own phases,unpackandcampleft out),defaultDays28, finale name "Hand Over the Keys" with theload-vantemplate's difficulty and beast. -
Step 2: Run
chemx test tests/unit/projectTypes.spec.ts. Expected: FAIL (module missing). -
Step 3: Implement.
movingOut.tsmapsMOVING_TEMPLATESof those four phases into suggestions (templateroomstorooms, a template with no rooms to"house",whoas is); shares[0.3, 0.3, 0.3, 0.1]; element"earth".springCleaning.tsis new content: 4-8 chores per phase across kitchen, bathroom, bedroom, living, garage, storage and"house", sized by band (e.g. little: "Match the socks" 1; kid: "Clear under the bed" 3; grown-up: "Deep clean the oven" 8); element"wind"; finalewho: "team", difficulty 8. -
Step 4: Run the spec. Expected: PASS.
-
Step 5: Lead commits
feat(projects): Spring Cleaning and Moving Out project types as data.
Files:
- Create:
src/lib/services/projects/projectSchedule.ts,src/lib/services/projects/floorBonus.ts - Test:
tests/unit/projectSchedule.spec.ts,tests/unit/floorBonus.spec.ts
Interfaces:
-
Produces:
-
interface PhaseWindow { phaseId: string; opensOn: string; dueOn: string }(YYYY-MM-DD, both inclusive) phaseWindows(phases: readonly { id: string; share: number }[], startOn: string, endOn: string): PhaseWindow[]-
minimumEndOn(phaseCount: number, startOn: string): string(the start plusphaseCount - 1days) -
extendWindows(windows: readonly PhaseWindow[], shares: Readonly<Record<string, number>>, clearedIds: ReadonlySet<string>, today: string, newEndOn: string): PhaseWindow[](throwsRangeErrorwhennewEndOnis beforetodayor too close for the uncleared phases) -
ROOF_PHASE_ID = "roof"; the roof's window is the last phase's window floorMultiplier(window: PhaseWindow, clearedOn: string): number-
floorBonusPool(quests: readonly { xp: number; gp: number }[], multiplier: number): { xp: number; gp: number }(floors(m - 1) * sum) splitFloorBonus(pool: { xp: number; gp: number }, workerIds: readonly string[], lastWorkerId: string): Record<string, { xp: number; gp: number }>
-
-
Step 1: Write the failing tests
-
phaseWindows(SPRING_SHARES, "2027-04-03", "2027-04-16")(14 days, shares 0.3/0.4/0.2/0.1) gives day counts[4, 6, 3, 1](cumulative share × total, rounded, each at least 1 day), the first opening 2027-04-03, each next opening the day after the previousdueOn, the lastdueOn2027-04-16. - 4 phases from 2027-04-03 ending 2027-04-04 throws
RangeError;minimumEndOn(4, "2027-04-03")is "2027-04-06". -
extendWindows: with phase 1 cleared, moving the end to 2027-04-23 on 2027-04-08 keeps phase 1's window and spreads the other three from 2027-04-08 to 2027-04-23 by their shares (renormalised); a new end before today throws. -
floorMultiplier({opensOn: "2027-04-03", dueOn: "2027-04-06"}, x)(4 days): "2027-04-06" → 1, "2027-04-05" → 1.5, "2027-04-04" → 2, "2027-04-03" → 2, "2027-04-08" (late) → 1. -
floorBonusPool([{xp: 300, gp: 30}, {xp: 500, gp: 50}], 1.5)→{xp: 400, gp: 40}. -
splitFloorBonus({xp: 400, gp: 41}, ["a", "b", "c"], "b")→ a{133, 13}, b{134, 15}, c{133, 13}.
-
-
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement. Dates are local calendar days (
dateKey,parseLocalDatefrompackCommon.ts);floorMultiplier = round1(1 + clamp(daysEarly / (phaseDays / 2), 0, 1))wherephaseDays = dueOn - opensOn + 1anddaysEarly = dueOn - clearedOnin whole days. -
Step 4: Run both specs. Expected: PASS.
-
Step 5: Lead commits
feat(projects): phase windows, extend, and the floor bonus.
Files:
- Create:
src/lib/services/projects/projectChores.ts - Test:
tests/unit/projectChores.spec.ts
Interfaces:
-
Consumes: Task 2's types;
PackHero,heroBandFor(packCommon.ts);MOVING_ROOMS(labels). -
Produces:
interface ProjectHouse { rooms: Partial<Record<MovingRoomId, number>>; basement?: boolean }interface ProjectRoom { id: string /* "kitchen", "bedroom-2" */; kind: MovingRoomId | "house"; name: string }interface ProjectChore { id: string; phaseId: string; roomId: string; name: string; difficulty: number; assignee: string[]; beastId?: string; minutes?: number; custom?: boolean }-
houseRoomsOf(house: ProjectHouse): ProjectRoom[](counted rooms numbered "Bedroom 1", "Bedroom 2"; a"house"room named "The Whole House") -
suggestChores(type: ProjectType, house: ProjectHouse, heroes: readonly PackHero[]): ProjectChore[](finale included,phaseId: "roof") choresFromLastRun(last: readonly ProjectChore[], heroes: readonly PackHero[]): ProjectChore[]
-
Step 1: Write the failing tests: one chore per suggestion per matching room (a kitchen suggestion with 1 kitchen gives one chore, a bedroom suggestion with 3 bedrooms gives three); rooms not ticked get none;
who: "little"goes to a little hero, round-robin across heroes of that band, falling back little → kid → grown-up when a band is empty;"team"assigns every hero; difficulty is clamped into the assignee's band range (little 1-2, kid 2-5, grown-up 5-13); ids are stable for the same input;choresFromLastRunkeeps names, sizes and custom chores, drops assignees who are no longer heroes and re-assigns those chores by band, and gives fresh ids. -
Step 2: Run
chemx test tests/unit/projectChores.spec.ts. Expected: FAIL. -
Step 3: Implement with the signatures above.
-
Step 4: Run the spec. Expected: PASS.
-
Step 5: Lead commits
feat(projects): the suggested chore list by room and age, and re-runs.
Files:
- Modify:
src/lib/engine/core/dungeons/types.ts(ClimbLock, L37) - Modify:
src/lib/services/temples/authoredDoors.ts(climbLockOf),src/lib/engine/dungeons/templeGraph.ts(LockKindL63, stairs door ~L274),src/lib/engine/dungeons/templeSolver.ts(passable) - Modify:
src/lib/services/temples/roomInteractions/travel.ts(stairsSheetL139,climbL154),src/lib/engine/core/dungeons/templeSystem/doors.ts(unlockClimbL333) - Modify: the room context type and its builder (find with
chemx q getLiveContext) to carryfloorCleared?: boolean - Test:
tests/unit/FloorClearedClimb.spec.ts
Interfaces:
-
Produces:
ClimbLock = 'small' | 'master' | 'floorCleared';LockKindgains"floorCleared"; the room context gainsfloorCleared?: boolean(true when every chore on the hero's current floor is done; set by Task 10 for project towers, absent elsewhere). -
Step 1: Write the failing tests
- Solver: a two-floor layout whose 1F stairs carry
content.locked: 'floorCleared'with two monster rooms on 1F issolvable, and itsorderlists both 1F monster rooms before any 2F room; the same layout with one 1F monster room walled off is not solvable and names the stairs inreasons. - Sheet:
stairsSheet(room, ctx, "up")withfloorClearedabsent or false shows the line "Clear every chore on this floor to climb" and no climb action; withfloorCleared: trueit shows the climb action and spends no key. -
unlockClimb(runs, id, level, pos, 'floorCleared')returns false and writes nothing (the climb is never stored as unlocked; it reads the context each time).
- Solver: a two-floor layout whose 1F stairs carry
-
Step 2: Run
chemx test tests/unit/FloorClearedClimb.spec.ts. Expected: FAIL. -
Step 3: Implement. In the solver,
floorClearedpasses once every fight room (monster, miniboss, boss, or a room withcontent.questIds) on the stairs' floor is in the reached set. Intravel.ts, afloorClearedlock readsctx.floorCleared; the line goes in the copy catalogue (three voices). -
Step 4: Run the spec plus
tests/unit/AuthoredBossDoor.spec.tsand the locked-climb specs (chemx q -g "isClimbUnlocked"in tests/unit). Expected: PASS. -
Step 5: Lead commits
feat(temples): a floor-cleared climb lock the solver and the stairs sheet know.
Files:
- Create:
src/lib/services/projects/projectTower.ts,src/lib/services/projects/towerRecord.ts,src/lib/services/projects/projectQuests.ts - Test:
tests/unit/projectTower.spec.ts,tests/unit/projectQuests.spec.ts
Interfaces:
-
Consumes: Tasks 1-5;
planHouse,HouseCell,PlacedCell(packs/movingTemple/housePlanner.ts,houseRooms.ts);solveTemple(templeSolver.ts),auditTemple(templeAudit.ts);rewardsFor. -
Produces:
-
PROJECT_DUE_TIME = "20:00",projectQuestId(projectId: string, choreId: string): string(pq-<projectId>-<choreId>) -
towerFloorKeys(phaseCount: number): string[](["1F", ..., "<n>F", "RF"]) interface ProjectTowerInput { projectId: string; type: ProjectType; house: ProjectHouse; chores: readonly ProjectChore[]; windows: readonly PhaseWindow[]; now: Date }interface ProjectTower { record: TempleInterface; quests: Achievement[]; floors: { floorKey: string; phaseId: string; window: PhaseWindow; questIds: string[] }[] }buildProjectTower(input: ProjectTowerInput): ProjectTowerquestsForProject(input: ProjectTowerInput, floorOf: (phaseId: string) => string): Achievement[]
-
-
Step 1: Write the failing tests
-
projectQuests: a chore becomes a quest with idpq-<projectId>-<choreId>,basicSchedule: "once",startsOn= its window'sopensOn,dueByTime: "20:00",project: { projectId, floor, dueOn }with nowokenAt(1F quests getwokenAt=now),assignee= the chore's,type"collaborate"when more than one assignee else"individual", rewardsrewardsFor(difficulty),adventureType: "beast",requiresApprovalfor a kid chore of difficulty 5+. -
projectTower,it.eachover a matrix of both types × room sets (Moving House fixtures'ROOM_SETSminus "nothing ticked") × families (tests/unit/movingHouseFixtures.ts): the record's id isgen-project-<typeId>,generated: true,elementfrom the type; its floors aretowerFloorKeys(phases); every chore's quest id sits in exactly one room'scontent.questIdson its phase's floor; the phase's biggest chore holds the floor's boss room (type: "boss", nolockOnEnter, no locked sides); each non-roof floor has onestairs-upwithcontent.locked: 'floorCleared'andtargetLevelthe next floor, whose arrival cell sits at the same row and column; the roof holds only its arrival and the finale; the entrance is on 1F;solveTemple(record.dungeonLayout)is solvable with no reasons andauditTemple(record).passedis true.
-
-
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement. Plan floor by floor with
planHouse(cells, seed)on a one-floor cell list (1F: anentrancecell; upper floors and the roof: astairsDownarrival cell pinned withatto the floor below's stairs-up position), onequestcell per room that has chores in the phase, the boss chore's room, and astairsUpcell hung off the boss room.towerRecord.tswrites the placed cells as rooms under the tower's floor keys (it does not reusehouseRecord.ts, whose rooms carry the Old House's keys and van). The seed isproject:<projectId>. A throw from the planner propagates with the floor named. -
Step 4: Run both specs. Expected: PASS.
-
Step 5: Lead commits
feat(projects): build a project tower: a floor per phase, the roof, its quests.
Files:
- Create:
src/lib/services/projects/projectRecord.ts,src/lib/databases/ProjectDb.ts - Modify:
src/lib/services/core/BackupService.ts(BACKUP_STORES, L112) - Test:
tests/unit/projectRecord.spec.ts,tests/unit/ProjectDb.spec.ts
Interfaces:
- Produces:
interface ProjectFloor { floorKey: string; phaseId: string; window: PhaseWindow; questIds: string[]; wokenAt?: string; clearedAt?: string; multiplier?: number; bonusPaidAt?: string }interface ProjectRecord { id: string; typeId: ProjectTypeId; templeId: string; status: "active" | "cleared" | "abandoned"; startOn: string; endOn: string; house: ProjectHouse; floors: ProjectFloor[]; chores: ProjectChore[]; reward: { text: string; earnedAt?: string; givenAt?: string; seenBy: string[] }; creditsSeenBy: string[]; clearedAt?: string }-
newProjectRecord(tower: ProjectTower, input: ProjectTowerInput, rewardText: string): ProjectRecord(1F'swokenAt= now) -
class ProjectDb extends DbStorageApiwithgetActive(): Promise<ProjectRecord | null>,getAll(): Promise<ProjectRecord[]>,save(record: ProjectRecord): Promise<void>,lastOfType(typeId): Promise<ProjectRecord | null>(latestcleared); store"__projects";getProjectDb()
- Step 1: Write the failing tests:
newProjectRecordcopies the tower's floors and windows, marks 1F woken, statusactive,reward.seenByandcreditsSeenByempty;ProjectDbround-trips a record,getActivereturns only theactiveone,lastOfTypethe newest cleared run of a type;BACKUP_STOREScontains"__projects". - Step 2: Run both specs. Expected: FAIL.
- Step 3: Implement following
FamilySettingsDb.ts's pattern (Storage withdriverOrder: [Drivers.IndexedDB, Drivers.LocalStorage], lazy singleton). - Step 4: Run both specs and
tests/unit/BackupService.spec.ts. Expected: PASS. - Step 5: Lead commits
feat(projects): the project record and the __projects store.
Files:
- Create:
src/lib/services/projects/projectProgress.ts(pure),src/lib/services/projects/projectService.ts(commits) - Modify:
src/lib/services/quests/QuestCompletionService.ts(completeQuestpaid branch ~L165,approveCompletion~L223) - Test:
tests/unit/projectProgress.spec.ts,tests/unit/projectService.spec.ts
Interfaces:
- Consumes: Tasks 3, 6, 7;
QuestLogEntry(QuestLogDb.ts);applyQuestRewards(userId, rewards, source, at?, label?). - Produces:
-
wakeFloor(project: ProjectRecord, floorKey: string, quests: readonly Achievement[], now: Date): { project: ProjectRecord; quests: Achievement[] }(setsfloor.wokenAtand each quest'sproject.wokenAt; returns unchanged copies when already woken) -
isFloorCleared(floor: ProjectFloor, quests: readonly Achievement[], paid: readonly QuestLogEntry[]): boolean(every floor quest that still exists has a paid entry) interface FloorSettlement { project: ProjectRecord; grants: Record<string, { xp: number; gp: number }>; wake?: string; finished: boolean }-
settleFloor(project: ProjectRecord, floorKey: string, quests: readonly Achievement[], paid: readonly QuestLogEntry[], now: Date): FloorSettlement | null(null when the floor is not cleared or already settled) -
onQuestPaid(quest: Achievement, at: string): Promise<FloorSettlement | null>inprojectService.ts; alsostartProject(input: ProjectTowerInput, rewardText: string): Promise<ProjectRecord>,extendProject(newEndOn: string, today: string): Promise<ProjectRecord>,abandonProject(): Promise<void>,markRewardGiven(): Promise<void>,markSeen(heroId: string, what: "reward" | "credits"): Promise<void>
-
- Step 1: Write the failing tests (
projectProgress):- "settles a cleared floor": 1F with two chores paid on 2027-04-04 in a 2027-04-03..06 window →
multiplier2,clearedAtset, grants split evenly between the two workers,wake"2F". - "wakes the next floor when a floor clears": the settlement's project has 2F
wokenAtset andonQuestPaidsaves 2F's quests withproject.wokenAt. - "dates the clear by the claim, not the approval": the last chore's entry has
completedAt2027-04-05 andreviewedAt2027-04-08 → multiplier 1.5. - "ignores a deleted chore": a floor quest id with no quest record does not block the clear.
- "settles a floor once": a second
settleFlooron a settled floor returns null. - "finishes on the roof": settling RF sets
status: "cleared",clearedAt,reward.earnedAt,finished: true. -
projectService(with in-memory DB fakes):onQuestPaidpays each grant throughapplyQuestRewards(heroId, { xp, gp, ap: 0 }, "project:<id>:<floor>", at, "Floor bonus: <phase name>");startProjectrefuses while one is active;abandonProjectdeletes open project quests and the temple record and setsabandoned;extendProjectrewrites uncleared floors' windows and their quests'startsOnandproject.dueOn.
- "settles a cleared floor": 1F with two chores paid on 2027-04-04 in a 2027-04-03..06 window →
- Step 2: Run both specs. Expected: FAIL.
- Step 3: Implement. In
QuestCompletionService, callonQuestPaid(quest, at)right after rewards are applied incompleteQuest's paid branch and inapproveCompletionwhenshouldPay, only forisProjectQuest(quest); a failure is caught and logged withdebug.warn, never blocking the payment. - Step 4: Run both specs plus
tests/unit/QuestCompletionService.spec.ts. Expected: PASS. - Step 5: Lead commits
feat(projects): floors settle on payment: the shared bonus, the next floor wakes, the roof finishes.
Files:
- Create:
src/lib/services/projects/projectWizard.ts,src/app/SideMenu/XpSettings/components/XpProjectWizardModal.vue - Modify:
src/app/SideMenu/XpSettings/components/PacksSettings.vue(an entry besideopenBuilder("moving"), L106, behindallowedToManagePacks()) - Test:
tests/unit/projectWizard.spec.ts,tests/unit/XpProjectWizardModal.spec.ts
Interfaces:
- Consumes:
XpWizardShell(src/components/organisms/WizardShell/XpWizardShell.vue, propssteps,draft, slots per step id),WizardStep<Draft>(src/lib/services/wizard/wizardSteps.ts); Tasks 2-4, 8. - Produces:
interface ProjectDraft { typeId?: ProjectTypeId; house: ProjectHouse; chores: ProjectChore[]; endOn: string; rewardText: string };PROJECT_WIZARD_STEPS: readonly WizardStep<ProjectDraft>[]with ids"type" | "house" | "chores" | "dates" | "build";newProjectDraft(today: string): ProjectDraft. - Step 1: Write the failing tests:
typeis valid only with a type chosen;houseneeds at least one room;choresneeds at least one chore in every phase and names the empty phase in its message;datesrefuses anendOnbeforeminimumEndOn(phases, today)with a message naming that date; choosing a type setsendOnto today +defaultDaysand fillschoresfromsuggestChores(orchoresFromLastRunwhenProjectDb.lastOfTypehas a run); the modal's Build callsstartProjectonce and closes; a second active project shows "Finish or abandon first" and no Build. - Step 2: Run both specs. Expected: FAIL.
- Step 3: Implement. The house step reuses the Moving House dialog's rooms markup (
MovingHouseModal.vueL40-77) as a small shared component if it lifts cleanly, else mirrors it. The chores step groups by phase, each row editable (name, size slider, assignee chip) with "Add a chore" per phase. Copy in the catalogue, three voices. - Step 4: Run both specs. Expected: PASS.
- Step 5: Lead commits
feat(projects): the Start a Project wizard.
Files:
- Create:
src/lib/services/projects/floorClock.ts,src/app/Console/TempleGrounds/components/TempleFloorClock.vue,src/app/Console/MyPortal/HomeTown/components/ProjectTowerSheet.vue - Modify:
src/app/Console/TempleGrounds/TempleGrounds.vue(header, beside the room name L50),src/app/Console/TempleGrounds/TempleGrounds.ts(load the project forgen-project-temples; passfloorClearedinto the room context; wake the floor on arrival; show the "Floor cleared!" reveal) - Modify:
src/app/Console/MyPortal/HomeTown/HomeTown.tsandHomeTown.vue(aproject-towerspot besideold-house),src/components/organisms/BuildingModel/voxel/buildingShapes.ts(aproject-towershape) - Test:
tests/unit/floorClock.spec.ts,tests/unit/ProjectTowerSheet.spec.ts
Interfaces:
- Consumes: Tasks 3, 5, 7, 8.
- Produces:
floorClockLine(floor: ProjectFloor, phaseName: string, today: string, voice: Voice): string(e.g. "Deep Clean: due Sat, 3 days left");bonusOfferLine(floor: ProjectFloor, today: string, voice: Voice): string | null("Clear this floor today for ×1.6", null when cleared or late). - Step 1: Write the failing tests: the clock reads "due today" on the due day and "late" after it; the offer equals
floorMultiplier(window, today)and disappears at ×1; the Hometown sheet shows the active tower's name, the current floor and its clock for heroes, and for the Game Master also "Move the end date" (callsextendProject) and "Abandon project" (confirms, thenabandonProject); with no active project but a cleared one it shows the trophy (Task 11's sheet). - Step 2: Run both specs. Expected: FAIL.
- Step 3: Implement. The voxel tower shape is a narrow stack of the floor count plus a roof, in the theme's colours; the BGM needs no change (the guard plays
<element>-templeforgen-temples). - Step 4: Run both specs plus
tests/unit/TempleGroundsModules.spec.ts. Expected: PASS. - Step 5: Lead commits
feat(projects): the tower in play: floor clock, bonus offer, Hometown tower.
Files:
- Create:
src/lib/services/copy/credits.ts(privacy gate: see Global Constraints),src/components/organisms/CreditsRoll/CreditsRoll.vue,src/app/Console/MyPortal/HomeTown/components/ProjectTrophySheet.vue,src/app/Admin/XpApprovals/components/ApprovalFamilyRewardRow.vue - Modify:
src/app/Admin/XpApprovals/XpApprovals.vue(the row at the top of the achievements tab),src/app/Admin/XpDashboard/dashboardTiles.ts(approvalsTotal, L38),src/app/Console/MyPortal/MyPortal.ts(the reward reveal and the credits on open) - Test:
tests/unit/credits.spec.ts,tests/unit/CreditsRoll.spec.ts,tests/unit/ApprovalFamilyRewardRow.spec.ts
Interfaces:
- Consumes: Tasks 7, 8.
- Produces:
-
interface Maker { name: string; role: string; birthYear: number },MAKERS: readonly Maker[],makerAge(maker: Maker, year: number): number(year - birthYear) -
interface HeroCredit { heroId: string; name: string; className: string; chores: number; bosses: number; floors: string[] },heroCredits(project: ProjectRecord, quests: readonly Achievement[], paid: readonly QuestLogEntry[], heroes: readonly { id: string; name: string; className: string }[]): HeroCredit[]
-
- Step 1: Write the failing tests
-
MAKERSin order: Producer "Xopher Pollard aka XP" (1984); Character Design / Original Chore List "Krislynn Night" (2007); World Map Design "Khronus Infinidee" (2010); Bestiary / Chore / Enemy Design "Gaia Rhae-Satori" (2014); Dungeon Mechanics "Ronan Tesla" (2014); Play Tester "Kairos Sol" (2020).makerAgein 2027 gives 43, 20, 17, 13, 13, 7. -
heroCreditscounts each hero's paid project chores, the boss rooms among them, and the floors they worked; a team finale counts for every assignee. -
CreditsRollrenders the heroes' part titled "The Heroes of " with the dates and days early, then "Do It For The XP" with each maker's role, name and age, then "Thank you for playing!"; it emitsdoneat the end or on skip. - The Approvals row shows "Family reward earned: " and "Mark as given" calls
markRewardGiven;approvalsTotalcounts an earned, ungiven reward as one. - On portal open a hero who is not in
reward.seenBygets the reward reveal once (thenmarkSeen(hero, "reward")); a hero not increditsSeenBygets the credits once after the roof is beaten. - The trophy sheet lists cleared runs (newest first) with dates and each floor's multiplier, "Watch the credits", and "Start again" (opens the wizard with
choresFromLastRun).
-
- Step 2: Run the three specs. Expected: FAIL.
- Step 3: Implement. The credits scroll is CSS-animated, StatusPlz font, cream text on the theme's window colour, the tower's music kept playing. Before the lead commits
credits.ts, the lead asks the owner to confirm the names and ages may be public. - Step 4: Run the three specs. Expected: PASS.
- Step 5: Lead commits
feat(projects): the finish: the family reward, the credits roll, the trophy.
Files:
- Modify:
src/app/SideMenu/XpSettings/components/PacksSettings.vue,MovingHouseModal.vue(the moving entry opens the project wizard on Moving Out, pre-filled) - Modify:
src/app/Console/MyPortal/HomeTown/HomeTown.ts(the Old House spot hides once a Moving Out project exists) - Delete:
src/lib/services/core/packs/movingTemple.ts,src/lib/services/core/packs/movingTemple/houseRecord.tsand their specs, once nothing imports them (houseRooms.tsandhousePlanner.tsstay) - Create:
tests/e2e/specs/project-tower.spec.js - Test:
tests/unit/movingOutPrefill.spec.ts
Interfaces:
- Consumes: Tasks 2, 4, 8, 9.
- Produces:
prefillMovingOut(answers: MovingAnswers | null, openMoveQuests: readonly Achievement[], heroes: readonly PackHero[]): ProjectDraft. - Step 1: Write the failing tests:
prefillMovingOuttakes the rooms from the last Moving House answers, turns each openmove-*quest into a custom chore on its phase (the pack's phase from its categorycat-move-<phase>;campandunpackquests left out) and keeps its assignee; starting the Moving Out project removes thegen-move-housetemple record and keeps its paid quests' log; the Old House spot is hidden when the active or last project is Moving Out. - Step 2: Run
chemx test tests/unit/movingOutPrefill.spec.ts. Expected: FAIL. - Step 3: Implement, then remove
buildMovingTemple,movingTempleBackupandhouseRecord.tswith their specs oncechemx q -g "buildMovingTemple"finds no importer. - Step 4: Run the spec and
tests/unit/movingPack.spec.ts. Expected: PASS. - Step 5: Write the e2e walk
tests/e2e/specs/project-tower.spec.js(390x844,cy.seedWorld(),cy.tapToWakeAudio()): the GM starts Spring Cleaning with two rooms and the suggested chores; a hero claims every 1F chore throughwindow.__xp; the header shows the ×2 offer and "Floor cleared!"; the hero climbs to 2F; the test fast-forwards by claiming the rest; the roof's victory plays the credits ("Xopher Pollard aka XP" visible) and the trophy shows in Hometown. Run against the shared dev server only ifcurl -s localhost:8100answers from this worktree's files, else on the worktree's own port:pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:<port> --spec tests/e2e/specs/project-tower.spec.js. Expected: all passing. - Step 6: Lead commits
feat(projects): Moving Out replaces the Old House; the project tower walk.
-
chemx typecheckandchemx lint(no errors), then push the branch, open the PR, and turn on auto-merge (gh pr merge <n> --auto --squash); the PR's Tests check is the full gate.